Add DhruvaNav autonomous exploration controller

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
This commit is contained in:
codetiger
2026-01-06 21:22:14 +05:30
parent 2c635ea183
commit 397eee1455
25 changed files with 6905 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "dhruva-nav"
version = "0.1.0"
edition = "2024"
description = "Navigation controller for VacuumTiger robot using VastuSLAM"
license = "MIT OR Apache-2.0"
[[bin]]
name = "dhruva-nav"
path = "src/main.rs"
[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"
[build-dependencies]
prost-build = "0.13"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true
+11
View File
@@ -0,0 +1,11 @@
//! Build script for compiling protobuf definitions
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Compile proto files
prost_build::compile_protos(&["proto/sangamio.proto"], &["proto/"])?;
// Rerun if proto files change
println!("cargo:rerun-if-changed=proto/sangamio.proto");
Ok(())
}
+21
View File
@@ -0,0 +1,21 @@
# DhruvaNav Configuration
# Navigation controller for VacuumTiger robot
[connection]
# SangamIO daemon address
# Use 127.0.0.1 for local mock device, or robot IP for real hardware
robot_ip = "127.0.0.1"
port = 5555
timeout_ms = 5000
[robot]
# CRL-200S robot parameters
wheel_base = 0.233 # Distance between wheels (meters)
ticks_per_meter = 4464.0 # Encoder ticks per meter of travel
max_linear_vel = 0.2 # Maximum linear velocity (m/s)
max_angular_vel = 0.5 # Maximum angular velocity (rad/s)
[output]
# Output file paths
map_path = "output/map.vastu"
svg_path = "output/map.svg"
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
../../sangam-io/proto/sangamio.proto
+2
View File
@@ -0,0 +1,2 @@
[toolchain]
channel = "nightly"
+349
View File
@@ -0,0 +1,349 @@
//! SangamIO TCP client for receiving sensor data and sending commands.
//!
//! Adapted from dhruva-slam/src/io/sangam_client.rs
use crate::error::{DhruvaError, Result};
use prost::Message as ProstMessage;
use std::collections::HashMap;
use std::io::Write;
use std::net::{TcpStream, UdpSocket};
use std::time::Duration;
// Include generated protobuf types
pub mod proto {
#[allow(clippy::enum_variant_names)]
pub mod sangamio {
include!(concat!(env!("OUT_DIR"), "/sangamio.rs"));
}
}
/// Sensor value types (converted from protobuf)
#[derive(Debug, Clone)]
pub enum SensorValue {
Bool(bool),
U32(u32),
PointCloud2D(Vec<LidarPoint>),
}
/// A single LiDAR point: (angle_rad, distance_m, quality)
pub type LidarPoint = (f32, f32, u8);
/// Parsed message from SangamIO
#[derive(Debug)]
pub struct SensorMessage {
pub group_id: String,
pub values: HashMap<String, SensorValue>,
}
impl SensorMessage {
/// Create from protobuf message
fn from_proto(msg: proto::sangamio::Message) -> Result<Self> {
match msg.payload {
Some(proto::sangamio::message::Payload::SensorGroup(sg)) => {
let values: HashMap<String, SensorValue> = sg
.values
.into_iter()
.filter_map(|(k, v)| {
let val = match v.value {
Some(proto::sangamio::sensor_value::Value::BoolVal(b)) => {
SensorValue::Bool(b)
}
Some(proto::sangamio::sensor_value::Value::U32Val(u)) => {
SensorValue::U32(u)
}
Some(proto::sangamio::sensor_value::Value::PointcloudVal(pc)) => {
SensorValue::PointCloud2D(
pc.points
.into_iter()
.map(|p| (p.angle_rad, p.distance_m, p.quality as u8))
.collect(),
)
}
_ => return None,
};
Some((k, val))
})
.collect();
Ok(Self {
group_id: sg.group_id,
values,
})
}
Some(proto::sangamio::message::Payload::Command(_)) => {
Err(DhruvaError::Protocol("Received command from server".into()))
}
None => Err(DhruvaError::Protocol("Empty payload".into())),
}
}
/// Check if this is a sensor status message
pub fn is_sensor_status(&self) -> bool {
self.group_id == "sensor_status"
}
/// Check if this is a lidar message
pub fn is_lidar(&self) -> bool {
self.group_id == "lidar"
}
/// Extract lidar point cloud if this is a lidar message
pub fn as_lidar(&self) -> Option<&Vec<LidarPoint>> {
if !self.is_lidar() {
return None;
}
if let Some(SensorValue::PointCloud2D(points)) = self.values.get("scan") {
Some(points)
} else {
None
}
}
/// Extract wheel encoder ticks (left, right)
pub fn encoder_ticks(&self) -> Option<(u16, u16)> {
if !self.is_sensor_status() {
return None;
}
let left = match self.values.get("wheel_left") {
Some(SensorValue::U32(v)) => *v as u16,
_ => return None,
};
let right = match self.values.get("wheel_right") {
Some(SensorValue::U32(v)) => *v as u16,
_ => return None,
};
Some((left, right))
}
/// Extract bumper state (left, right)
pub fn bumper_state(&self) -> Option<(bool, bool)> {
if !self.is_sensor_status() {
return None;
}
let left = match self.values.get("bumper_left") {
Some(SensorValue::Bool(v)) => *v,
_ => false,
};
let right = match self.values.get("bumper_right") {
Some(SensorValue::Bool(v)) => *v,
_ => false,
};
Some((left, right))
}
/// Extract cliff sensor states (left_side, left_front, right_front, right_side)
pub fn cliff_state(&self) -> Option<(bool, bool, bool, bool)> {
if !self.is_sensor_status() {
return None;
}
let left_side = match self.values.get("cliff_left_side") {
Some(SensorValue::Bool(v)) => *v,
_ => false,
};
let left_front = match self.values.get("cliff_left_front") {
Some(SensorValue::Bool(v)) => *v,
_ => false,
};
let right_front = match self.values.get("cliff_right_front") {
Some(SensorValue::Bool(v)) => *v,
_ => false,
};
let right_side = match self.values.get("cliff_right_side") {
Some(SensorValue::Bool(v)) => *v,
_ => false,
};
Some((left_side, left_front, right_front, right_side))
}
}
/// Default buffer size (64KB)
const DEFAULT_BUFFER_SIZE: usize = 65536;
/// TCP/UDP client for SangamIO daemon
pub struct SangamClient {
stream: TcpStream,
udp_socket: UdpSocket,
buffer: Vec<u8>,
}
impl SangamClient {
/// Connect with timeout (uses same port for TCP commands and UDP sensor receiving)
pub fn connect_timeout(addr: &str, timeout: Duration) -> Result<Self> {
let sock_addr: std::net::SocketAddr = addr
.parse()
.map_err(|e| DhruvaError::Config(format!("Invalid address: {}", e)))?;
// Use the same port for UDP as TCP (they're different protocols, can coexist)
Self::connect_with_udp_port(addr, timeout, sock_addr.port())
}
/// Connect with timeout and custom UDP port
pub fn connect_with_udp_port(addr: &str, timeout: Duration, udp_port: u16) -> Result<Self> {
let addr: std::net::SocketAddr = addr
.parse()
.map_err(|e| DhruvaError::Config(format!("Invalid address: {}", e)))?;
let stream = TcpStream::connect_timeout(&addr, timeout)?;
// Bind UDP socket to receive sensor data from SangamIO
// TCP and UDP can use the same port number (different protocols)
let udp_bind_addr = format!("0.0.0.0:{}", udp_port);
let udp_socket = UdpSocket::bind(&udp_bind_addr).map_err(|e| {
DhruvaError::Config(format!("Failed to bind UDP to {}: {}", udp_bind_addr, e))
})?;
udp_socket.set_nonblocking(true)?;
tracing::info!("UDP socket bound to port {} for sensor data", udp_port);
Ok(Self {
stream,
udp_socket,
buffer: vec![0u8; DEFAULT_BUFFER_SIZE],
})
}
/// Set read timeout for TCP (UDP remains non-blocking for buffer draining)
pub fn set_timeout(&mut self, timeout: Option<Duration>) -> Result<()> {
self.stream.set_read_timeout(timeout)?;
// Keep UDP non-blocking to allow draining the buffer efficiently
// The main loop will drain all available messages before processing
self.udp_socket.set_nonblocking(true)?;
Ok(())
}
/// Receive sensor message from UDP (non-blocking)
pub fn recv_udp(&mut self) -> Result<Option<SensorMessage>> {
match self.udp_socket.recv(&mut self.buffer) {
Ok(len) => {
if len < 4 {
return Ok(None);
}
// Read length prefix
let msg_len = u32::from_be_bytes([
self.buffer[0],
self.buffer[1],
self.buffer[2],
self.buffer[3],
]) as usize;
if len < 4 + msg_len {
return Err(DhruvaError::Protocol("Incomplete UDP message".into()));
}
// Decode protobuf
let proto_msg = proto::sangamio::Message::decode(&self.buffer[4..4 + msg_len])?;
SensorMessage::from_proto(proto_msg).map(Some)
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
Err(e) if e.kind() == std::io::ErrorKind::TimedOut => Ok(None),
Err(e) => Err(DhruvaError::Connection(e)),
}
}
/// Send drive velocity command
pub fn send_drive_command(&mut self, linear: f32, angular: f32) -> Result<()> {
use proto::sangamio::{
ComponentAction, ComponentControl, Message as ProtoMessage,
command::Command as CommandPayload, component_action::ActionType,
};
let mut config = HashMap::new();
config.insert(
"linear".to_string(),
proto::sangamio::SensorValue {
value: Some(proto::sangamio::sensor_value::Value::F32Val(linear)),
},
);
config.insert(
"angular".to_string(),
proto::sangamio::SensorValue {
value: Some(proto::sangamio::sensor_value::Value::F32Val(angular)),
},
);
let command = ProtoMessage {
topic: "command".to_string(),
payload: Some(proto::sangamio::message::Payload::Command(
proto::sangamio::Command {
command: Some(CommandPayload::ComponentControl(ComponentControl {
id: "drive".to_string(),
action: Some(ComponentAction {
r#type: ActionType::Configure as i32,
config,
}),
})),
},
)),
};
self.send_proto(&command)
}
/// Send stop command (set velocity to zero)
pub fn send_stop(&mut self) -> Result<()> {
self.send_drive_command(0.0, 0.0)
}
/// Enable the drive component (must call before sending velocity commands)
pub fn enable_drive(&mut self) -> Result<()> {
use proto::sangamio::{
ComponentAction, ComponentControl, Message as ProtoMessage,
command::Command as CommandPayload, component_action::ActionType,
};
let command = ProtoMessage {
topic: "command".to_string(),
payload: Some(proto::sangamio::message::Payload::Command(
proto::sangamio::Command {
command: Some(CommandPayload::ComponentControl(ComponentControl {
id: "drive".to_string(),
action: Some(ComponentAction {
r#type: ActionType::Enable as i32,
config: HashMap::new(),
}),
})),
},
)),
};
self.send_proto(&command)
}
/// Enable the lidar component
pub fn enable_lidar(&mut self) -> Result<()> {
use proto::sangamio::{
ComponentAction, ComponentControl, Message as ProtoMessage,
command::Command as CommandPayload, component_action::ActionType,
};
let command = ProtoMessage {
topic: "command".to_string(),
payload: Some(proto::sangamio::message::Payload::Command(
proto::sangamio::Command {
command: Some(CommandPayload::ComponentControl(ComponentControl {
id: "lidar".to_string(),
action: Some(ComponentAction {
r#type: ActionType::Enable as i32,
config: HashMap::new(),
}),
})),
},
)),
};
self.send_proto(&command)
}
/// Send raw protobuf message
fn send_proto(&mut self, msg: &proto::sangamio::Message) -> Result<()> {
let encoded = msg.encode_to_vec();
let len = encoded.len() as u32;
// Write length prefix (big-endian)
self.stream.write_all(&len.to_be_bytes())?;
// Write payload
self.stream.write_all(&encoded)?;
self.stream.flush()?;
Ok(())
}
}
+275
View File
@@ -0,0 +1,275 @@
//! Configuration loading for DhruvaNav
use crate::error::{DhruvaError, Result};
use serde::Deserialize;
use std::path::Path;
/// Main configuration structure
#[derive(Clone, Debug, Deserialize)]
pub struct DhruvaConfig {
pub connection: ConnectionConfig,
pub robot: RobotConfig,
#[serde(default)]
pub exploration: ExplorationConfig,
pub output: OutputConfig,
}
/// Network connection settings
#[derive(Clone, Debug, Deserialize)]
pub struct ConnectionConfig {
/// Robot IP address (default: 127.0.0.1 for local mock)
#[serde(default = "default_robot_ip")]
pub robot_ip: String,
/// TCP port number (default: 5555)
#[serde(default = "default_port")]
pub port: u16,
/// Connection timeout in milliseconds (default: 5000)
#[serde(default = "default_timeout")]
pub timeout_ms: u64,
}
/// Robot physical parameters
#[derive(Clone, Debug, Deserialize)]
pub struct RobotConfig {
/// Distance between wheels in meters (default: 0.233)
#[serde(default = "default_wheel_base")]
pub wheel_base: f32,
/// Encoder ticks per meter of travel (default: 4464.0)
#[serde(default = "default_ticks_per_meter")]
pub ticks_per_meter: f32,
/// Maximum linear velocity in m/s (default: 0.2)
#[serde(default = "default_max_linear")]
pub max_linear_vel: f32,
/// Maximum angular velocity in rad/s (default: 0.5)
#[serde(default = "default_max_angular")]
pub max_angular_vel: f32,
}
/// Output configuration
#[derive(Clone, Debug, Deserialize)]
pub struct OutputConfig {
/// Path to save the map file
#[serde(default = "default_map_path")]
pub map_path: String,
/// Path to save SVG visualization
#[serde(default = "default_svg_path")]
pub svg_path: String,
}
/// Exploration configuration
#[derive(Clone, Debug, Deserialize)]
pub struct ExplorationConfig {
/// Robot radius for obstacle inflation (meters)
#[serde(default = "default_robot_radius")]
pub robot_radius: f32,
/// Safety margin beyond robot radius (meters)
#[serde(default = "default_safety_margin")]
pub safety_margin: f32,
/// Wall penalty distance for path planning (meters)
#[serde(default = "default_wall_penalty_distance")]
pub wall_penalty_distance: f32,
/// Minimum frontier cluster size
#[serde(default = "default_min_frontier_size")]
pub min_frontier_size: usize,
/// Frontier refresh interval (seconds)
#[serde(default = "default_frontier_refresh")]
pub frontier_refresh_interval: f32,
/// Return to start position when complete
#[serde(default = "default_return_to_start")]
pub return_to_start: bool,
/// Goal tolerance for waypoints (meters)
#[serde(default = "default_goal_tolerance")]
pub goal_tolerance: f32,
// --- Recovery and stuck detection parameters ---
/// Maximum replans allowed for a single frontier before blacklisting
#[serde(default = "default_max_replans_per_frontier")]
pub max_replans_per_frontier: usize,
/// Timeout for making progress toward frontier (seconds)
#[serde(default = "default_frontier_timeout_secs")]
pub frontier_timeout_secs: f32,
/// Minimum distance to consider progress made (meters)
#[serde(default = "default_min_progress_distance")]
pub min_progress_distance: f32,
/// Cooldown before blacklisted frontier can be retried (seconds)
#[serde(default = "default_blacklist_cooldown_secs")]
pub blacklist_cooldown_secs: f32,
/// Distance threshold to consider robot stuck (meters)
#[serde(default = "default_stuck_distance_threshold")]
pub stuck_distance_threshold: f32,
/// Time window for stuck detection (seconds)
#[serde(default = "default_stuck_time_window_secs")]
pub stuck_time_window_secs: f32,
/// Distance to back up during recovery (meters)
#[serde(default = "default_recovery_backup_distance")]
pub recovery_backup_distance: f32,
/// Angle to rotate during recovery (radians)
#[serde(default = "default_recovery_rotation_rad")]
pub recovery_rotation_rad: f32,
/// Maximum recovery attempts per frontier before blacklisting
#[serde(default = "default_max_recovery_attempts")]
pub max_recovery_attempts: usize,
}
impl Default for ExplorationConfig {
fn default() -> Self {
Self {
robot_radius: default_robot_radius(),
safety_margin: default_safety_margin(),
wall_penalty_distance: default_wall_penalty_distance(),
min_frontier_size: default_min_frontier_size(),
frontier_refresh_interval: default_frontier_refresh(),
return_to_start: default_return_to_start(),
goal_tolerance: default_goal_tolerance(),
// Recovery and stuck detection
max_replans_per_frontier: default_max_replans_per_frontier(),
frontier_timeout_secs: default_frontier_timeout_secs(),
min_progress_distance: default_min_progress_distance(),
blacklist_cooldown_secs: default_blacklist_cooldown_secs(),
stuck_distance_threshold: default_stuck_distance_threshold(),
stuck_time_window_secs: default_stuck_time_window_secs(),
recovery_backup_distance: default_recovery_backup_distance(),
recovery_rotation_rad: default_recovery_rotation_rad(),
max_recovery_attempts: default_max_recovery_attempts(),
}
}
}
// Default value functions
fn default_robot_ip() -> String {
"127.0.0.1".to_string()
}
fn default_port() -> u16 {
5555
}
fn default_timeout() -> u64 {
5000
}
fn default_wheel_base() -> f32 {
0.233
}
fn default_ticks_per_meter() -> f32 {
4464.0
}
fn default_max_linear() -> f32 {
0.2
}
fn default_max_angular() -> f32 {
0.5
}
fn default_map_path() -> String {
"output/map.vastu".to_string()
}
fn default_svg_path() -> String {
"output/map.svg".to_string()
}
// Exploration defaults
fn default_robot_radius() -> f32 {
0.10
}
fn default_safety_margin() -> f32 {
0.15
} // 25cm total clearance from robot center
fn default_wall_penalty_distance() -> f32 {
0.30
} // Penalize paths within 30cm of walls
fn default_min_frontier_size() -> usize {
5
}
fn default_frontier_refresh() -> f32 {
1.5
}
fn default_return_to_start() -> bool {
true
}
fn default_goal_tolerance() -> f32 {
0.15
}
// Recovery and stuck detection defaults
fn default_max_replans_per_frontier() -> usize {
3
}
fn default_frontier_timeout_secs() -> f32 {
60.0
}
fn default_min_progress_distance() -> f32 {
0.10
}
fn default_blacklist_cooldown_secs() -> f32 {
120.0
}
fn default_stuck_distance_threshold() -> f32 {
0.02
}
fn default_stuck_time_window_secs() -> f32 {
3.0
}
fn default_recovery_backup_distance() -> f32 {
0.10
}
fn default_recovery_rotation_rad() -> f32 {
0.5
}
fn default_max_recovery_attempts() -> usize {
2
}
impl Default for DhruvaConfig {
fn default() -> Self {
Self {
connection: ConnectionConfig {
robot_ip: default_robot_ip(),
port: default_port(),
timeout_ms: default_timeout(),
},
robot: RobotConfig {
wheel_base: default_wheel_base(),
ticks_per_meter: default_ticks_per_meter(),
max_linear_vel: default_max_linear(),
max_angular_vel: default_max_angular(),
},
exploration: ExplorationConfig::default(),
output: OutputConfig {
map_path: default_map_path(),
svg_path: default_svg_path(),
},
}
}
}
impl DhruvaConfig {
/// Load configuration from a TOML file
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.map_err(|e| DhruvaError::Config(format!("Failed to read config file: {}", e)))?;
let config: DhruvaConfig = toml::from_str(&content)?;
Ok(config)
}
/// Get the full address string for connection
pub fn address(&self) -> String {
format!("{}:{}", self.connection.robot_ip, self.connection.port)
}
}
+33
View File
@@ -0,0 +1,33 @@
//! Error types for DhruvaNav
use thiserror::Error;
/// DhruvaNav error type
#[derive(Error, Debug)]
pub enum DhruvaError {
#[error("Connection failed: {0}")]
Connection(#[from] std::io::Error),
#[error("Protocol error: {0}")]
Protocol(String),
#[error("Configuration error: {0}")]
Config(String),
#[error("SLAM error: {0}")]
Slam(String),
}
impl From<prost::DecodeError> for DhruvaError {
fn from(e: prost::DecodeError) -> Self {
DhruvaError::Protocol(e.to_string())
}
}
impl From<toml::de::Error> for DhruvaError {
fn from(e: toml::de::Error) -> Self {
DhruvaError::Config(e.to_string())
}
}
pub type Result<T> = std::result::Result<T, DhruvaError>;
File diff suppressed because it is too large Load Diff
+384
View File
@@ -0,0 +1,384 @@
//! Path follower using pure pursuit algorithm.
//!
//! Follows planned paths by computing velocity commands that steer
//! the robot toward a lookahead point on the path.
use vastu_slam::{Pose2D, WorldPoint};
use crate::planning::{PathSegment, SmoothedPath};
/// Configuration for path following.
#[derive(Clone, Debug)]
pub struct FollowerConfig {
/// Distance tolerance for reaching waypoints (meters)
pub waypoint_tolerance: f32,
/// Distance tolerance for reaching final goal (meters)
pub goal_tolerance: f32,
/// Maximum linear velocity (m/s)
pub max_linear_vel: f32,
/// Maximum angular velocity (rad/s)
pub max_angular_vel: f32,
/// Proportional gain for angular control
pub kp_angular: f32,
}
impl Default for FollowerConfig {
fn default() -> Self {
Self {
waypoint_tolerance: 0.10,
goal_tolerance: 0.15,
max_linear_vel: 0.2,
max_angular_vel: 0.5,
kp_angular: 2.0,
}
}
}
/// State of path following.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FollowState {
/// Following the path
Following,
/// Performing a point turn
Turning,
/// Reached the goal
Complete,
/// Path is empty or invalid
NoPath,
}
/// Path follower using pure pursuit.
pub struct PathFollower {
config: FollowerConfig,
/// Current path to follow
path: Option<SmoothedPath>,
/// Current segment index
current_segment: usize,
/// Current state
state: FollowState,
}
impl PathFollower {
/// Create a new path follower with configuration.
pub fn new(config: FollowerConfig) -> Self {
Self {
config,
path: None,
current_segment: 0,
state: FollowState::NoPath,
}
}
/// Set a new path to follow.
pub fn set_path(&mut self, path: SmoothedPath) {
if path.segments.is_empty() {
tracing::warn!("set_path: received empty path (0 segments)");
self.path = None;
self.current_segment = 0;
self.state = FollowState::NoPath;
} else {
tracing::info!(
"set_path: {} segments, {:.2}m total",
path.segments.len(),
path.total_length
);
self.path = Some(path);
self.current_segment = 0;
self.state = FollowState::Following;
}
}
/// Clear the current path.
pub fn clear_path(&mut self) {
self.path = None;
self.current_segment = 0;
self.state = FollowState::NoPath;
}
/// Check if path following is complete.
pub fn is_complete(&self) -> bool {
self.state == FollowState::Complete
}
/// Check if there's no path to follow.
pub fn has_path(&self) -> bool {
self.path.is_some() && !self.path.as_ref().unwrap().segments.is_empty()
}
/// Get the final goal point.
pub fn goal(&self) -> Option<WorldPoint> {
self.path
.as_ref()
.and_then(|p| p.segments.last().map(|s| s.end_point()))
}
/// Compute velocity command to follow the path.
///
/// Returns (linear_velocity, angular_velocity).
pub fn compute_velocity(&mut self, current_pose: Pose2D) -> (f32, f32) {
let path = match &self.path {
Some(p) => p,
None => {
self.state = FollowState::NoPath;
tracing::warn!("compute_velocity: No path set on follower");
return (0.0, 0.0);
}
};
if self.current_segment >= path.segments.len() {
self.state = FollowState::Complete;
tracing::debug!("Path complete: all {} segments done", path.segments.len());
return (0.0, 0.0);
}
let segment = &path.segments[self.current_segment];
tracing::debug!(
"Following segment {}/{}: {:?}",
self.current_segment + 1,
path.segments.len(),
match segment {
PathSegment::Line { start, end } => format!(
"Line ({:.2},{:.2})->({:.2},{:.2})",
start.x, start.y, end.x, end.y
),
PathSegment::PointTurn { to_angle, .. } =>
format!("PointTurn to {:.1}°", to_angle.to_degrees()),
}
);
let current_pos = WorldPoint::new(current_pose.x, current_pose.y);
match segment {
PathSegment::Line { start, end } => {
self.follow_line(current_pose, current_pos, *start, *end)
}
PathSegment::PointTurn {
position, to_angle, ..
} => self.execute_point_turn(current_pose, *position, *to_angle),
}
}
/// Follow a line segment - simplified direct approach.
fn follow_line(
&mut self,
current_pose: Pose2D,
current_pos: WorldPoint,
_start: WorldPoint,
end: WorldPoint,
) -> (f32, f32) {
let distance_to_end = current_pos.distance(&end);
// Check if we've reached the end of this segment
if distance_to_end < self.config.waypoint_tolerance {
tracing::debug!("Segment complete (dist {:.3}m)", distance_to_end);
self.advance_segment();
return self.compute_velocity(current_pose);
}
// Simple approach: drive directly toward the segment end point
// This is more robust than pure pursuit for our use case
self.drive_to_point(current_pose, end, distance_to_end)
}
/// Execute a point turn (rotate in place).
fn execute_point_turn(
&mut self,
current_pose: Pose2D,
_position: WorldPoint,
target_angle: f32,
) -> (f32, f32) {
// Compute angle error
let angle_error = Self::normalize_angle(target_angle - current_pose.theta);
// Check if turn is complete (10° tolerance)
if angle_error.abs() < 0.17 {
self.advance_segment();
return self.compute_velocity(current_pose);
}
// Pure rotation with gentler control to prevent overshoot
self.state = FollowState::Turning;
let turn_max_angular = self.config.max_angular_vel * 0.5; // 50% of max for point turns
let angular_gain = 0.6; // Gentle gain for point turns
let angular = (angular_gain * angle_error).clamp(-turn_max_angular, turn_max_angular);
tracing::debug!(
"point_turn: target={:.1}°, current={:.1}°, error={:.1}°, angular={:.3}",
target_angle.to_degrees(),
current_pose.theta.to_degrees(),
angle_error.to_degrees(),
angular
);
(0.0, angular)
}
/// Simple drive-to-point controller.
/// Uses proportional control with angle-dependent speed reduction.
fn drive_to_point(
&mut self,
current_pose: Pose2D,
target: WorldPoint,
distance: f32,
) -> (f32, f32) {
self.state = FollowState::Following;
// Compute angle to target
let dx = target.x - current_pose.x;
let dy = target.y - current_pose.y;
let target_angle = dy.atan2(dx);
let angle_error = Self::normalize_angle(target_angle - current_pose.theta);
let angle_error_deg = angle_error.to_degrees();
let angle_error_abs = angle_error.abs();
// Proportional control with angle-dependent speed scaling
// Key insight: ALWAYS maintain some forward motion to prevent spinning loops
// Angular velocity: proportional to angle error, clamped
let kp_angular = self.config.kp_angular;
let angular = (kp_angular * angle_error)
.clamp(-self.config.max_angular_vel, self.config.max_angular_vel);
// Linear velocity: scale down based on angle error
// - At 0° error: full speed
// - At 90° error: 20% speed (still moving forward!)
// - At 180° error: 10% speed (prevents spinning in place)
let angle_factor = if angle_error_abs < 0.17 {
// < 10°: full speed
1.0
} else if angle_error_abs < 1.57 {
// 10-90°: linear interpolation from 1.0 to 0.2
1.0 - 0.8 * (angle_error_abs - 0.17) / (1.57 - 0.17)
} else {
// > 90°: minimum speed (10-20% to prevent pure spinning)
0.1 + 0.1 * (std::f32::consts::PI - angle_error_abs) / (std::f32::consts::PI - 1.57)
};
// Distance-based speed: slow down as we approach target
let distance_speed = (distance * 0.8).min(self.config.max_linear_vel);
// Minimum forward speed when far from target to prevent getting stuck
let min_forward_speed = if distance > 0.15 { 0.03 } else { 0.0 };
let linear = (distance_speed * angle_factor).max(min_forward_speed);
tracing::debug!(
"drive_to_point: dist={:.2}m, angle_err={:.1}°, angle_factor={:.2}, vel=({:.3},{:.3})",
distance,
angle_error_deg,
angle_factor,
linear,
angular
);
(linear, angular)
}
/// Advance to the next segment.
fn advance_segment(&mut self) {
self.current_segment += 1;
if let Some(path) = &self.path {
if self.current_segment >= path.segments.len() {
self.state = FollowState::Complete;
} else {
self.state = FollowState::Following;
}
}
}
/// Normalize angle to [-pi, pi].
#[inline]
fn normalize_angle(angle: f32) -> f32 {
let mut a = angle;
while a > std::f32::consts::PI {
a -= 2.0 * std::f32::consts::PI;
}
while a < -std::f32::consts::PI {
a += 2.0 * std::f32::consts::PI;
}
a
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::planning::PathSegment;
fn create_test_path() -> SmoothedPath {
SmoothedPath {
segments: vec![
PathSegment::Line {
start: WorldPoint::new(0.0, 0.0),
end: WorldPoint::new(1.0, 0.0),
},
PathSegment::Line {
start: WorldPoint::new(1.0, 0.0),
end: WorldPoint::new(1.0, 1.0),
},
],
total_length: 2.0,
estimated_time: 10.0,
}
}
#[test]
fn test_follower_creation() {
let follower = PathFollower::new(FollowerConfig::default());
assert!(!follower.has_path());
}
#[test]
fn test_set_path() {
let mut follower = PathFollower::new(FollowerConfig::default());
let path = create_test_path();
follower.set_path(path);
assert!(follower.has_path());
}
#[test]
fn test_follow_straight_line() {
let mut follower = PathFollower::new(FollowerConfig::default());
let path = SmoothedPath {
segments: vec![PathSegment::Line {
start: WorldPoint::new(0.0, 0.0),
end: WorldPoint::new(1.0, 0.0),
}],
total_length: 1.0,
estimated_time: 5.0,
};
follower.set_path(path);
// Robot at origin, facing forward
let pose = Pose2D::new(0.0, 0.0, 0.0);
let (linear, angular) = follower.compute_velocity(pose);
// Should move forward with minimal turning
assert!(linear > 0.0);
assert!(angular.abs() < 0.1);
}
#[test]
fn test_complete_detection() {
let mut follower = PathFollower::new(FollowerConfig::default());
let path = SmoothedPath {
segments: vec![PathSegment::Line {
start: WorldPoint::new(0.0, 0.0),
end: WorldPoint::new(0.1, 0.0),
}],
total_length: 0.1,
estimated_time: 0.5,
};
follower.set_path(path);
// Robot near the end
let pose = Pose2D::new(0.09, 0.0, 0.0);
let (_linear, _angular) = follower.compute_velocity(pose);
assert!(follower.is_complete());
}
}
+517
View File
@@ -0,0 +1,517 @@
//! Frontier detection for autonomous exploration.
//!
//! Identifies boundaries between explored and unexplored space,
//! clusters them into frontier regions, and scores them for selection.
use std::collections::{HashSet, VecDeque};
use vastu_slam::{CellType, GridCoord, GridStorage, Pose2D, WorldPoint};
use crate::planning::CostMap;
/// Configuration for frontier detection.
#[derive(Clone, Debug)]
pub struct FrontierConfig {
/// Minimum number of cells for a valid frontier cluster
pub min_cluster_size: usize,
/// Maximum number of frontiers to consider
pub max_frontiers: usize,
/// Weight for frontier size in scoring
pub size_weight: f32,
/// Weight for distance penalty in scoring
pub distance_weight: f32,
/// Weight for information gain (unexplored neighbors)
pub info_gain_weight: f32,
/// Distance threshold for clustering frontier cells
pub cluster_distance: i32,
}
impl Default for FrontierConfig {
fn default() -> Self {
Self {
min_cluster_size: 5,
max_frontiers: 20,
// Weights tuned for normalized scoring (all components in [0,1] range)
// Higher distance_weight prioritizes nearby frontiers to reduce backtracking
size_weight: 0.5,
distance_weight: 1.5,
info_gain_weight: 1.0,
cluster_distance: 3,
}
}
}
/// Unique identifier for a frontier based on its centroid.
/// Used for blacklisting and tracking frontiers across updates.
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
pub struct FrontierId {
pub centroid: GridCoord,
}
/// A frontier region (cluster of frontier cells).
#[derive(Clone, Debug)]
pub struct Frontier {
/// Centroid of the frontier (in grid coordinates)
pub centroid: GridCoord,
/// Centroid in world coordinates
pub world_centroid: WorldPoint,
/// Size (number of cells)
pub size: usize,
/// Estimated information gain (unexplored cells nearby)
pub info_gain: f32,
/// Euclidean distance to robot (meters)
pub distance: f32,
/// Score (higher is better, set by normalize_scores)
pub score: f32,
}
impl Frontier {
/// Get a unique identifier for this frontier.
pub fn id(&self) -> FrontierId {
FrontierId {
centroid: self.centroid,
}
}
}
/// Frontier detector for exploration.
pub struct FrontierDetector {
config: FrontierConfig,
}
impl FrontierDetector {
/// Create a new frontier detector with configuration.
pub fn new(config: FrontierConfig) -> Self {
Self { config }
}
/// Detect all frontiers in the grid.
///
/// Returns frontiers sorted by score (highest first).
pub fn detect(
&self,
grid: &GridStorage,
cost_map: &CostMap,
robot_pose: Pose2D,
) -> Vec<Frontier> {
// Step 1: Find all frontier cells
let frontier_cells = self.find_frontier_cells(grid, cost_map);
if frontier_cells.is_empty() {
return Vec::new();
}
// Step 2: Cluster frontier cells
let clusters = self.cluster_frontiers(&frontier_cells);
// Step 3: Filter and create frontiers (with raw info_gain/distance)
let mut frontiers: Vec<Frontier> = clusters
.into_iter()
.filter(|cluster| cluster.len() >= self.config.min_cluster_size)
.map(|cluster| self.create_frontier(cluster, grid, cost_map, robot_pose))
.collect();
// Step 3.5: Filter unreachable frontiers
// Removes frontiers where no valid navigation target exists
// (e.g., frontiers too close to walls discovered after initial detection)
let pre_filter_count = frontiers.len();
frontiers.retain(|frontier| {
self.navigation_target(frontier, cost_map, robot_pose)
.is_some()
});
if frontiers.len() < pre_filter_count {
tracing::debug!(
"Filtered {} unreachable frontiers (kept {})",
pre_filter_count - frontiers.len(),
frontiers.len()
);
}
// Step 4: Normalize scores across all frontiers
// This makes weights meaningful by scaling info_gain, distance, size to [0,1]
self.normalize_scores(&mut frontiers);
// Step 5: Sort by normalized score (descending)
frontiers.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
// Step 6: Limit number of frontiers
frontiers.truncate(self.config.max_frontiers);
frontiers
}
/// Find all frontier cells (floor cells adjacent to unknown).
///
/// Note: Frontier cells may be in the inflation zone (near unknown space).
/// The path planner will find a safe approach point.
fn find_frontier_cells(&self, grid: &GridStorage, _cost_map: &CostMap) -> Vec<GridCoord> {
let mut frontier_cells = Vec::new();
let width = grid.width();
let height = grid.height();
// 4-connected neighbors for frontier detection
let neighbors = [
GridCoord::new(-1, 0),
GridCoord::new(1, 0),
GridCoord::new(0, -1),
GridCoord::new(0, 1),
];
for y in 1..(height as i32 - 1) {
for x in 1..(width as i32 - 1) {
let coord = GridCoord::new(x, y);
// Must be a floor cell
if grid.get_type(coord) != CellType::Floor {
continue;
}
// Check if adjacent to unknown
let is_frontier = neighbors.iter().any(|offset| {
let neighbor = GridCoord::new(coord.x + offset.x, coord.y + offset.y);
grid.get_type(neighbor) == CellType::Unknown
});
if is_frontier {
frontier_cells.push(coord);
}
}
}
frontier_cells
}
/// Cluster frontier cells using flood-fill.
fn cluster_frontiers(&self, frontier_cells: &[GridCoord]) -> Vec<Vec<GridCoord>> {
if frontier_cells.is_empty() {
return Vec::new();
}
// Create set for fast lookup
let frontier_set: HashSet<GridCoord> = frontier_cells.iter().copied().collect();
let mut visited: HashSet<GridCoord> = HashSet::new();
let mut clusters = Vec::new();
// 8-connected neighbors for clustering
let neighbors = [
GridCoord::new(-1, 0),
GridCoord::new(1, 0),
GridCoord::new(0, -1),
GridCoord::new(0, 1),
GridCoord::new(-1, -1),
GridCoord::new(1, -1),
GridCoord::new(-1, 1),
GridCoord::new(1, 1),
];
for &start_cell in frontier_cells {
if visited.contains(&start_cell) {
continue;
}
// BFS to find connected frontier cells
let mut cluster = Vec::new();
let mut queue = VecDeque::new();
queue.push_back(start_cell);
visited.insert(start_cell);
while let Some(current) = queue.pop_front() {
cluster.push(current);
for &_offset in &neighbors {
// Check cells within cluster distance
for dy in -self.config.cluster_distance..=self.config.cluster_distance {
for dx in -self.config.cluster_distance..=self.config.cluster_distance {
let candidate = GridCoord::new(current.x + dx, current.y + dy);
if frontier_set.contains(&candidate) && !visited.contains(&candidate) {
visited.insert(candidate);
queue.push_back(candidate);
}
}
}
}
}
if !cluster.is_empty() {
clusters.push(cluster);
}
}
clusters
}
/// Create a Frontier from a cluster of cells.
/// Note: score is set to 0.0 here and will be computed by normalize_scores().
fn create_frontier(
&self,
cells: Vec<GridCoord>,
grid: &GridStorage,
cost_map: &CostMap,
robot_pose: Pose2D,
) -> Frontier {
let size = cells.len();
// Compute centroid
let sum_x: i32 = cells.iter().map(|c| c.x).sum();
let sum_y: i32 = cells.iter().map(|c| c.y).sum();
let centroid = GridCoord::new(sum_x / size as i32, sum_y / size as i32);
// Convert to world coordinates
let world_centroid = cost_map.grid_to_world(centroid);
// Compute information gain (count unknown cells near frontier)
let info_gain = self.estimate_info_gain(&cells, grid);
// Compute distance to robot
let robot_pos = WorldPoint::new(robot_pose.x, robot_pose.y);
let distance = robot_pos.distance(&world_centroid);
// Score will be set by normalize_scores() after all frontiers are created
let score = 0.0;
Frontier {
centroid,
world_centroid,
size,
info_gain,
distance,
score,
}
}
/// Estimate information gain for a frontier.
fn estimate_info_gain(&self, cells: &[GridCoord], grid: &GridStorage) -> f32 {
let mut unknown_count = 0;
let mut checked = HashSet::new();
// Sample cells to check (for efficiency, check subset)
let sample_step = (cells.len() / 10).max(1);
for (i, &cell) in cells.iter().enumerate() {
if i % sample_step != 0 {
continue;
}
// Check 5x5 neighborhood
for dy in -2..=2 {
for dx in -2..=2 {
let neighbor = GridCoord::new(cell.x + dx, cell.y + dy);
if checked.insert(neighbor) && grid.get_type(neighbor) == CellType::Unknown {
unknown_count += 1;
}
}
}
}
unknown_count as f32
}
/// Normalize frontier scores to make weights meaningful.
///
/// Normalizes info_gain, distance, and size to [0, 1] range before applying
/// configured weights. This ensures distance can compete with info_gain
/// regardless of their raw value ranges.
fn normalize_scores(&self, frontiers: &mut [Frontier]) {
if frontiers.is_empty() {
return;
}
const EPSILON: f32 = 1e-6;
// Find min/max for each component
let (min_info, max_info) = frontiers
.iter()
.map(|f| f.info_gain)
.fold((f32::MAX, f32::MIN), |(min, max), v| {
(min.min(v), max.max(v))
});
let (min_size, max_size) = frontiers
.iter()
.map(|f| (f.size as f32).sqrt())
.fold((f32::MAX, f32::MIN), |(min, max), v| {
(min.min(v), max.max(v))
});
let (min_dist, max_dist) = frontiers
.iter()
.map(|f| f.distance)
.fold((f32::MAX, f32::MIN), |(min, max), v| {
(min.min(v), max.max(v))
});
// Compute ranges (avoid division by zero)
let info_range = (max_info - min_info).max(EPSILON);
let size_range = (max_size - min_size).max(EPSILON);
let dist_range = (max_dist - min_dist).max(EPSILON);
// Compute normalized scores for each frontier
for frontier in frontiers.iter_mut() {
let norm_info = (frontier.info_gain - min_info) / info_range;
let norm_size = ((frontier.size as f32).sqrt() - min_size) / size_range;
let norm_dist = (frontier.distance - min_dist) / dist_range;
frontier.score = self.config.size_weight * norm_size
+ self.config.info_gain_weight * norm_info
- self.config.distance_weight * norm_dist;
}
}
/// Get the navigation target for a frontier.
///
/// Returns a traversable point near the frontier that the robot can safely reach.
/// Uses a multi-stage approach:
/// 1. Try small safety offset toward robot
/// 2. Try centroid directly
/// 3. BFS to find closest traversable cell (up to 5m radius)
///
/// Returns `None` if no traversable point is found.
pub fn navigation_target(
&self,
frontier: &Frontier,
cost_map: &CostMap,
robot_pose: Pose2D,
) -> Option<WorldPoint> {
let centroid = frontier.world_centroid;
let robot_pos = WorldPoint::new(robot_pose.x, robot_pose.y);
// Calculate direction from centroid toward robot (into explored space)
let dx = robot_pos.x - centroid.x;
let dy = robot_pos.y - centroid.y;
let dist_to_centroid = (dx * dx + dy * dy).sqrt();
// Stage 1: Try small safety offset (5cm) toward robot
let safety_offset = 0.05;
let min_path_distance = 0.20;
if dist_to_centroid > min_path_distance + safety_offset {
let scale = safety_offset / dist_to_centroid;
let safe_target = WorldPoint::new(centroid.x + dx * scale, centroid.y + dy * scale);
let grid_coord = cost_map.world_to_grid(safe_target);
if cost_map.is_traversable(grid_coord) {
tracing::debug!(
"Frontier target: centroid ({:.2},{:.2}) -> safe ({:.2},{:.2}), dist={:.2}",
centroid.x,
centroid.y,
safe_target.x,
safe_target.y,
dist_to_centroid
);
return Some(safe_target);
}
}
// Stage 2: Try centroid directly
let centroid_coord = cost_map.world_to_grid(centroid);
if cost_map.is_traversable(centroid_coord) {
tracing::debug!(
"Frontier target: using centroid directly ({:.2},{:.2})",
centroid.x,
centroid.y
);
return Some(centroid);
}
// Stage 3: BFS to find closest reachable cell (up to 5m)
let max_search_radius = 5.0; // meters
if let Some(reachable) = cost_map.find_closest_reachable_world(centroid, max_search_radius)
{
let distance_from_centroid = centroid.distance(&reachable);
tracing::info!(
"Frontier target: found reachable point {:.2}m from centroid at ({:.2},{:.2})",
distance_from_centroid,
reachable.x,
reachable.y
);
return Some(reachable);
}
// No reachable point found within search radius
tracing::warn!(
"Frontier target: no reachable point found within {:.1}m of ({:.2},{:.2})",
max_search_radius,
centroid.x,
centroid.y
);
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_grid() -> GridStorage {
// 50x50 grid with partial exploration (larger grid to avoid inflation issues)
let mut grid = GridStorage::new(50, 50, 0.05, WorldPoint::ZERO);
// Mark explored area (floor) in center region
for y in 10..40 {
for x in 10..25 {
grid.set_type(GridCoord::new(x, y), CellType::Floor);
}
}
// Rest is unknown (default)
grid
}
#[test]
fn test_frontier_detection() {
let grid = create_test_grid();
// Use smaller robot radius for test to avoid all frontiers being in inflation zone
let cost_map = CostMap::from_grid(&grid, 0.05, 0.02, 0.10);
let detector = FrontierDetector::new(FrontierConfig::default());
let robot_pose = Pose2D::new(0.75, 1.0, 0.0);
let frontiers = detector.detect(&grid, &cost_map, robot_pose);
// Should find frontiers at the boundary of explored area
assert!(
!frontiers.is_empty(),
"Expected frontiers at Floor/Unknown boundary"
);
}
#[test]
fn test_no_frontiers_fully_explored() {
// Fully explored grid (all floor)
let mut grid = GridStorage::new(20, 20, 0.05, WorldPoint::ZERO);
for y in 0..20 {
for x in 0..20 {
grid.set_type(GridCoord::new(x, y), CellType::Floor);
}
}
let cost_map = CostMap::from_grid(&grid, 0.05, 0.02, 0.10);
let detector = FrontierDetector::new(FrontierConfig::default());
let robot_pose = Pose2D::new(0.5, 0.5, 0.0);
let frontiers = detector.detect(&grid, &cost_map, robot_pose);
// No frontiers (no unknown cells)
assert!(frontiers.is_empty());
}
#[test]
fn test_frontier_scoring() {
let grid = create_test_grid();
let cost_map = CostMap::from_grid(&grid, 0.05, 0.02, 0.10);
let detector = FrontierDetector::new(FrontierConfig::default());
let robot_pose = Pose2D::new(0.75, 1.0, 0.0);
let frontiers = detector.detect(&grid, &cost_map, robot_pose);
if frontiers.len() > 1 {
// Frontiers should be sorted by score (descending)
for i in 0..frontiers.len() - 1 {
assert!(frontiers[i].score >= frontiers[i + 1].score);
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
//! Autonomous exploration module.
//!
//! This module provides frontier-based exploration capabilities:
//! - Frontier detection and clustering
//! - Path following with pure pursuit
//! - Exploration state machine
mod explorer;
mod follower;
mod frontier;
pub use explorer::{ExplorationState, ExplorationStep, Explorer, ExplorerConfig};
pub use follower::FollowerConfig;
pub use frontier::FrontierConfig;
+266
View File
@@ -0,0 +1,266 @@
//! DhruvaNav - Navigation Controller for VacuumTiger
//!
//! A navigation controller that connects to SangamIO, performs autonomous
//! frontier-based exploration, and builds maps using VastuSLAM.
//!
//! ## Multi-Threaded Architecture
//!
//! DhruvaNav uses three threads for concurrent operation:
//!
//! - **Sensor Thread** (~100Hz): Reads UDP sensor data, updates odometry,
//! checks safety sensors, sends velocity commands
//! - **Mapping Thread** (~5-10Hz): Receives lidar scans, performs scan matching,
//! updates the occupancy grid map
//! - **Exploration Thread** (~20Hz): Reads the map, detects frontiers,
//! plans paths, computes velocity commands
#![feature(portable_simd)]
mod client;
mod config;
mod error;
mod exploration;
mod odometry;
mod planning;
mod shared;
mod threads;
mod utils;
use config::DhruvaConfig;
use error::{DhruvaError, Result};
use shared::{SharedDebugViz, SharedGrid, SharedState, SharedTrajectory};
use threads::spawn_threads;
use std::path::Path;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use tracing::{error, info, warn};
use vastu_slam::{GridStorage, MapConfig, Pose2D};
fn main() -> Result<()> {
// Initialize logging
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("dhruva_nav=info".parse().unwrap()),
)
.init();
// Parse command line arguments
let args: Vec<String> = std::env::args().collect();
let config = if args.len() > 1 && !args[1].starts_with("--") {
// Load config from file
let config_path = Path::new(&args[1]);
info!("Loading configuration from {:?}", config_path);
DhruvaConfig::load(config_path)?
} else {
// Check for --robot argument
let robot_ip = args
.iter()
.position(|a| a == "--robot")
.and_then(|i| args.get(i + 1))
.cloned();
let mut config = if Path::new("dhruva.toml").exists() {
info!("Loading configuration from dhruva.toml");
DhruvaConfig::load(Path::new("dhruva.toml"))?
} else {
info!("Using default configuration");
DhruvaConfig::default()
};
// Override robot IP if provided
if let Some(ip) = robot_ip {
info!("Using robot IP: {}", ip);
config.connection.robot_ip = ip;
}
config
};
info!("DhruvaNav v{}", env!("CARGO_PKG_VERSION"));
info!(
"Connecting to {}:{}",
config.connection.robot_ip, config.connection.port
);
// Log configuration
let total_clearance = config.exploration.robot_radius + config.exploration.safety_margin;
info!(
"Wall clearance: {:.2}m (robot radius {:.2}m + safety margin {:.2}m)",
total_clearance, config.exploration.robot_radius, config.exploration.safety_margin
);
// Initialize shared state
// Robot starts at origin - map is built relative to initial position
let start_pose = Pose2D::new(0.0, 0.0, 0.0);
info!("Start pose: origin (0, 0, 0°)");
let shared_state = Arc::new(SharedState::new(start_pose));
// Initialize shared grid storage
let map_config = MapConfig::default();
let initial_grid = GridStorage::new(
map_config.grid.initial_width,
map_config.grid.initial_height,
map_config.grid.resolution,
vastu_slam::WorldPoint::ZERO,
);
let shared_grid: SharedGrid = Arc::new(RwLock::new(initial_grid));
// Spawn worker threads
info!("Starting multi-threaded exploration...");
let handles = spawn_threads(
config.clone(),
Arc::clone(&shared_state),
Arc::clone(&shared_grid),
)?;
// Main thread: Monitor and wait for completion
let check_interval = Duration::from_millis(500);
loop {
std::thread::sleep(check_interval);
// Check for safety stop
if shared_state.is_safety_stop() {
warn!(
"Safety stop: {}",
shared_state
.safety_reason()
.unwrap_or_else(|| "unknown".to_string())
);
break;
}
// Check for exploration complete
if shared_state.is_exploration_complete() {
info!("Exploration completed successfully");
break;
}
// Check if threads are still alive
if handles.sensor.is_finished()
|| handles.mapping.is_finished()
|| handles.exploration.is_finished()
{
warn!("A worker thread exited unexpectedly");
break;
}
}
// Signal shutdown to all threads
shared_state.signal_shutdown();
// Wait for threads to finish (with timeout)
info!("Waiting for threads to finish...");
let join_timeout = Duration::from_secs(5);
let join_start = std::time::Instant::now();
// Join threads
if let Err(e) = handles.sensor.join() {
error!("Sensor thread panicked: {:?}", e);
}
if join_start.elapsed() < join_timeout
&& let Err(e) = handles.mapping.join()
{
error!("Mapping thread panicked: {:?}", e);
}
if join_start.elapsed() < join_timeout
&& let Err(e) = handles.exploration.join()
{
error!("Exploration thread panicked: {:?}", e);
}
// Save the map with trajectory and debug visualization
info!("Saving map...");
save_map(
&config,
&shared_grid,
&handles.trajectory,
&handles.debug_viz,
)?;
info!("DhruvaNav finished");
Ok(())
}
/// Save the map to files.
fn save_map(
config: &DhruvaConfig,
shared_grid: &SharedGrid,
shared_trajectory: &SharedTrajectory,
shared_debug_viz: &SharedDebugViz,
) -> Result<()> {
let grid_guard = shared_grid
.read()
.map_err(|e| DhruvaError::Slam(format!("Failed to lock grid: {}", e)))?;
let grid: &GridStorage = &grid_guard;
// Get statistics
let counts = grid.count_by_type();
let explored_area = (counts.floor + counts.wall) as f32 * grid.resolution() * grid.resolution();
info!(
"Map statistics: {:.2}m² explored, {} floor cells, {} wall cells",
explored_area, counts.floor, counts.wall
);
// Create output directory if needed
let map_path = Path::new(&config.output.map_path);
if let Some(parent) = map_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| DhruvaError::Slam(format!("Failed to create output directory: {}", e)))?;
}
// Save .vastu file
vastu_slam::io::save_vastu(grid, map_path)
.map_err(|e| DhruvaError::Slam(format!("Failed to save map: {}", e)))?;
info!("Map saved to {:?}", map_path);
// Read trajectory for SVG visualization
let trajectory = shared_trajectory
.read()
.map_err(|e| DhruvaError::Slam(format!("Failed to lock trajectory: {}", e)))?
.clone();
// Read debug visualization data
let debug_viz = shared_debug_viz
.read()
.map_err(|e| DhruvaError::Slam(format!("Failed to lock debug viz: {}", e)))?
.clone();
info!(
"Visualization data: {} trajectory poses, {} frontier targets, {} waypoints",
trajectory.len(),
debug_viz.visited_frontiers.len(),
debug_viz.waypoints.len()
);
// Save SVG visualization with trajectory and debug info
let svg_path = Path::new(&config.output.svg_path);
let svg_config = vastu_slam::io::SvgConfig {
scale: 50.0,
trajectory_width: 2.0,
..Default::default()
};
// Calculate markers at 0.5m intervals along trajectory
let markers = vastu_slam::io::markers_by_distance(&trajectory, 0.5);
let visualizer = vastu_slam::io::SvgVisualizer::new(grid.clone(), svg_config)
.with_title("DhruvaNav Exploration")
.with_estimated(trajectory, markers)
.with_frontiers(debug_viz.visited_frontiers)
.with_waypoints(debug_viz.waypoints);
visualizer
.save(svg_path)
.map_err(|e| DhruvaError::Slam(format!("Failed to save SVG: {}", e)))?;
info!("SVG saved to {:?}", svg_path);
Ok(())
}
+137
View File
@@ -0,0 +1,137 @@
//! Wheel encoder-based odometry calculator
//!
//! Computes robot pose from differential drive wheel encoders.
use vastu_slam::Pose2D;
use crate::utils::normalize_angle;
/// Odometry calculator for differential drive robot
pub struct Odometry {
/// Distance between wheels in meters
wheel_base: f32,
/// Encoder ticks per meter of travel
ticks_per_meter: f32,
/// Previous left wheel encoder value
prev_left: u16,
/// Previous right wheel encoder value
prev_right: u16,
/// Current estimated pose
pose: Pose2D,
/// Whether we have received the first encoder reading
initialized: bool,
}
impl Odometry {
/// Create a new odometry calculator
///
/// # Arguments
/// * `wheel_base` - Distance between wheels in meters (CRL-200S: 0.233m)
/// * `ticks_per_meter` - Encoder ticks per meter (CRL-200S: 4464.0)
/// * `start_pose` - Initial pose of the robot
pub fn new(wheel_base: f32, ticks_per_meter: f32, start_pose: Pose2D) -> Self {
Self {
wheel_base,
ticks_per_meter,
prev_left: 0,
prev_right: 0,
pose: start_pose,
initialized: false,
}
}
/// Update odometry with new encoder readings
///
/// # Arguments
/// * `left` - Left wheel encoder tick count (u16, wrapping)
/// * `right` - Right wheel encoder tick count (u16, wrapping)
///
/// # Returns
/// The updated pose
pub fn update(&mut self, left: u16, right: u16) -> Pose2D {
if !self.initialized {
// First reading - just store the values
self.prev_left = left;
self.prev_right = right;
self.initialized = true;
return self.pose;
}
// Calculate tick deltas (handle u16 wraparound using wrapping_sub)
let dl = left.wrapping_sub(self.prev_left) as i16;
let dr = right.wrapping_sub(self.prev_right) as i16;
// Update stored values
self.prev_left = left;
self.prev_right = right;
// Skip if no movement
if dl == 0 && dr == 0 {
return self.pose;
}
// Convert ticks to meters
let dist_left = dl as f32 / self.ticks_per_meter;
let dist_right = dr as f32 / self.ticks_per_meter;
// Differential drive kinematics
let dist = (dist_left + dist_right) / 2.0;
let dtheta = (dist_right - dist_left) / self.wheel_base;
// Update pose using mid-point integration
// For more accuracy with rotation, we use the average heading
let mid_theta = self.pose.theta + dtheta / 2.0;
self.pose.x += dist * mid_theta.cos();
self.pose.y += dist * mid_theta.sin();
self.pose.theta += dtheta;
// Normalize theta to [-pi, pi]
self.pose.theta = normalize_angle(self.pose.theta);
self.pose
}
/// Get the current pose
pub fn pose(&self) -> Pose2D {
self.pose
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_straight_line() {
let mut odom = Odometry::new(0.233, 4464.0, Pose2D::new(0.0, 0.0, 0.0));
// Initialize
odom.update(0, 0);
// Move forward 1 meter (4464 ticks on each wheel)
let pose = odom.update(4464, 4464);
assert!((pose.x - 1.0).abs() < 0.01);
assert!(pose.y.abs() < 0.01);
assert!(pose.theta.abs() < 0.01);
}
#[test]
fn test_rotation() {
let mut odom = Odometry::new(0.233, 4464.0, Pose2D::new(0.0, 0.0, 0.0));
// Initialize
odom.update(0, 0);
// Rotate in place: right wheel forward, left wheel backward
// For a 90 degree turn: arc length = theta * radius = (pi/2) * (0.233/2)
// ticks = arc_length * ticks_per_meter
let arc_ticks = (std::f32::consts::FRAC_PI_2 * 0.233 / 2.0 * 4464.0) as u16;
let pose = odom.update(0u16.wrapping_sub(arc_ticks), arc_ticks);
// Should have rotated approximately 90 degrees (pi/2 radians)
assert!(pose.x.abs() < 0.05); // Minimal translation
assert!(pose.y.abs() < 0.05);
assert!((pose.theta - std::f32::consts::FRAC_PI_2).abs() < 0.1);
}
}
+594
View File
@@ -0,0 +1,594 @@
//! Cost map with obstacle inflation and wall distance penalties.
//!
//! Provides efficient path planning by pre-computing traversability costs
//! and distance-to-obstacle fields using SIMD-optimized algorithms.
use std::collections::{HashSet, VecDeque};
use std::simd::{cmp::SimdPartialOrd, f32x4};
use vastu_slam::{CellType, GridCoord, GridStorage, WorldPoint};
/// Number of SIMD lanes for f32 operations
const LANES: usize = 4;
/// Cost thresholds for path planning
pub mod costs {
/// Safe to traverse, no penalty
pub const FREE: u8 = 0;
/// Close to wall, penalized but traversable
pub const NEAR_OBSTACLE: u8 = 50;
/// Within robot radius, should avoid
pub const INSCRIBED: u8 = 254;
/// Obstacle or unknown, blocked
pub const LETHAL: u8 = 255;
}
/// Cost map for path planning with obstacle inflation and wall distance penalty.
///
/// The cost map pre-computes:
/// 1. Traversability costs (lethal, inscribed, inflated, free)
/// 2. Distance field (distance to nearest obstacle in cells)
#[derive(Clone, Debug)]
pub struct CostMap {
/// Grid dimensions
width: usize,
height: usize,
/// Resolution in meters per cell
resolution: f32,
/// Grid origin in world coordinates
origin: WorldPoint,
/// Cost values: 0 = free, 255 = lethal obstacle
costs: Vec<u8>,
/// Distance to nearest obstacle (in cells) for each cell
distance_field: Vec<f32>,
/// Wall penalty distance in cells
wall_penalty_cells: usize,
}
impl CostMap {
/// Build cost map from occupancy grid.
///
/// # Arguments
/// * `grid` - Source occupancy grid
/// * `robot_radius` - Robot radius in meters
/// * `safety_margin` - Extra clearance beyond robot radius in meters
/// * `wall_penalty_dist` - Distance within which to penalize wall proximity in meters
pub fn from_grid(
grid: &GridStorage,
robot_radius: f32,
safety_margin: f32,
wall_penalty_dist: f32,
) -> Self {
let width = grid.width();
let height = grid.height();
let resolution = grid.resolution();
let (origin, _) = grid.bounds();
// Convert distances to cells
let robot_radius_cells = (robot_radius / resolution).ceil() as usize;
let safety_margin_cells = (safety_margin / resolution).ceil() as usize;
let wall_penalty_cells = (wall_penalty_dist / resolution).ceil() as usize;
let total_cells = width * height;
let mut costs = vec![costs::FREE; total_cells];
let mut distance_field = vec![f32::MAX; total_cells];
// Initialize: mark obstacles and build initial distance field
let mut obstacle_queue = VecDeque::with_capacity(total_cells / 10);
for y in 0..height as i32 {
for x in 0..width as i32 {
let coord = GridCoord::new(x, y);
let cell_type = grid.get_type(coord);
let idx = (y as usize) * width + (x as usize);
if cell_type.is_obstacle() || cell_type == CellType::Unknown {
costs[idx] = costs::LETHAL;
distance_field[idx] = 0.0;
obstacle_queue.push_back((x, y));
}
}
}
// Compute distance field using Brushfire algorithm
Self::compute_distance_field_brushfire(
&mut distance_field,
width,
height,
&mut obstacle_queue,
);
// Apply cost layers based on distance field
Self::apply_cost_layers(
&mut costs,
&distance_field,
width,
height,
robot_radius_cells,
safety_margin_cells,
wall_penalty_cells,
);
Self {
width,
height,
resolution,
origin,
costs,
distance_field,
wall_penalty_cells,
}
}
/// Compute distance field using Brushfire (BFS) algorithm.
fn compute_distance_field_brushfire(
distance_field: &mut [f32],
width: usize,
height: usize,
queue: &mut VecDeque<(i32, i32)>,
) {
let sqrt2 = 1.414f32;
// 8-connected neighbors with distances
let neighbors = [
(-1, 0, 1.0),
(1, 0, 1.0),
(0, -1, 1.0),
(0, 1, 1.0),
(-1, -1, sqrt2),
(1, -1, sqrt2),
(-1, 1, sqrt2),
(1, 1, sqrt2),
];
while let Some((x, y)) = queue.pop_front() {
let current_idx = (y as usize) * width + (x as usize);
let current_dist = distance_field[current_idx];
for &(dx, dy, cost) in &neighbors {
let nx = x + dx;
let ny = y + dy;
// Bounds check
if nx < 0 || ny < 0 || nx >= width as i32 || ny >= height as i32 {
continue;
}
let neighbor_idx = (ny as usize) * width + (nx as usize);
let new_dist = current_dist + cost;
if new_dist < distance_field[neighbor_idx] {
distance_field[neighbor_idx] = new_dist;
queue.push_back((nx, ny));
}
}
}
}
/// Apply cost layers based on distance field.
fn apply_cost_layers(
costs: &mut [u8],
distance_field: &[f32],
width: usize,
height: usize,
robot_radius_cells: usize,
safety_margin_cells: usize,
wall_penalty_cells: usize,
) {
let inscribed_dist = robot_radius_cells as f32;
let inflation_dist = (robot_radius_cells + safety_margin_cells) as f32;
let penalty_dist = wall_penalty_cells as f32;
// SIMD-optimized cost computation
let total_cells = width * height;
let inscribed_vec = f32x4::splat(inscribed_dist);
let inflation_vec = f32x4::splat(inflation_dist);
let penalty_vec = f32x4::splat(penalty_dist);
// Process 4 cells at a time
let aligned_len = (total_cells / LANES) * LANES;
for i in (0..aligned_len).step_by(LANES) {
// Skip if already lethal
if costs[i] == costs::LETHAL
|| costs[i + 1] == costs::LETHAL
|| costs[i + 2] == costs::LETHAL
|| costs[i + 3] == costs::LETHAL
{
// Process individually for mixed lethal/non-lethal
for j in 0..LANES {
if costs[i + j] != costs::LETHAL {
costs[i + j] = Self::compute_cell_cost(
distance_field[i + j],
inscribed_dist,
inflation_dist,
penalty_dist,
);
}
}
continue;
}
let dist = f32x4::from_slice(&distance_field[i..]);
// Check inscribed radius
let is_inscribed = dist.simd_le(inscribed_vec);
// Check inflation radius
let is_inflated = dist.simd_le(inflation_vec);
// Check wall penalty distance
let is_near_wall = dist.simd_le(penalty_vec);
// Convert masks to costs
for j in 0..LANES {
if is_inscribed.test(j) {
costs[i + j] = costs::INSCRIBED;
} else if is_inflated.test(j) {
// Exponential decay in inflation zone
let d = distance_field[i + j];
let ratio = (d - inscribed_dist) / (inflation_dist - inscribed_dist);
let cost = costs::INSCRIBED
- ((costs::INSCRIBED - costs::NEAR_OBSTACLE) as f32 * ratio) as u8;
costs[i + j] = cost.max(costs::NEAR_OBSTACLE);
} else if is_near_wall.test(j) {
// Linear penalty in wall proximity zone
let d = distance_field[i + j];
let ratio = 1.0 - (d - inflation_dist) / (penalty_dist - inflation_dist);
let cost = (costs::NEAR_OBSTACLE as f32 * ratio.max(0.0)) as u8;
costs[i + j] = cost.max(1);
}
// Otherwise stays FREE (0)
}
}
// Handle remaining cells
for i in aligned_len..total_cells {
if costs[i] != costs::LETHAL {
costs[i] = Self::compute_cell_cost(
distance_field[i],
inscribed_dist,
inflation_dist,
penalty_dist,
);
}
}
}
/// Compute cost for a single cell based on distance to obstacle.
#[inline]
fn compute_cell_cost(
distance: f32,
inscribed_dist: f32,
inflation_dist: f32,
penalty_dist: f32,
) -> u8 {
if distance <= inscribed_dist {
costs::INSCRIBED
} else if distance <= inflation_dist {
let ratio = (distance - inscribed_dist) / (inflation_dist - inscribed_dist);
let cost =
costs::INSCRIBED - ((costs::INSCRIBED - costs::NEAR_OBSTACLE) as f32 * ratio) as u8;
cost.max(costs::NEAR_OBSTACLE)
} else if distance <= penalty_dist {
let ratio = 1.0 - (distance - inflation_dist) / (penalty_dist - inflation_dist);
let cost = (costs::NEAR_OBSTACLE as f32 * ratio.max(0.0)) as u8;
cost.max(1)
} else {
costs::FREE
}
}
/// Get cost at grid coordinate.
#[inline]
pub fn cost(&self, coord: GridCoord) -> u8 {
if coord.x < 0 || coord.y < 0 {
return costs::LETHAL;
}
let x = coord.x as usize;
let y = coord.y as usize;
if x >= self.width || y >= self.height {
return costs::LETHAL;
}
self.costs[y * self.width + x]
}
/// Get distance to nearest obstacle at grid coordinate (in cells).
#[inline]
pub fn obstacle_distance(&self, coord: GridCoord) -> f32 {
if coord.x < 0 || coord.y < 0 {
return 0.0;
}
let x = coord.x as usize;
let y = coord.y as usize;
if x >= self.width || y >= self.height {
return 0.0;
}
self.distance_field[y * self.width + x]
}
/// Check if a cell is traversable (cost < INSCRIBED).
#[inline]
pub fn is_traversable(&self, coord: GridCoord) -> bool {
self.cost(coord) < costs::INSCRIBED
}
/// Find the closest traversable cell to a target using BFS.
///
/// Returns `None` if no traversable cell is found within `max_radius` cells.
///
/// # Arguments
/// * `target` - The target coordinate to find a reachable point near
/// * `max_radius` - Maximum search radius in cells
pub fn find_closest_reachable_to(
&self,
target: GridCoord,
max_radius: i32,
) -> Option<GridCoord> {
// Check if target itself is traversable
if self.is_traversable(target) {
return Some(target);
}
// BFS to find closest traversable cell
let mut queue = VecDeque::new();
let mut visited = HashSet::new();
queue.push_back(target);
visited.insert((target.x, target.y));
// 8-connected neighbors for search
let neighbors = [
(-1, 0),
(1, 0),
(0, -1),
(0, 1),
(-1, -1),
(1, -1),
(-1, 1),
(1, 1),
];
while let Some(current) = queue.pop_front() {
// Check distance from target (use Chebyshev distance for rectangular search)
let dx = (current.x - target.x).abs();
let dy = (current.y - target.y).abs();
if dx > max_radius || dy > max_radius {
continue; // Outside search radius
}
// Check if this cell is traversable
if self.is_traversable(current) {
return Some(current);
}
// Add unvisited neighbors to queue
for (ndx, ndy) in &neighbors {
let nx = current.x + ndx;
let ny = current.y + ndy;
// Bounds check
if nx < 0 || ny < 0 || nx >= self.width as i32 || ny >= self.height as i32 {
continue;
}
if !visited.contains(&(nx, ny)) {
visited.insert((nx, ny));
queue.push_back(GridCoord::new(nx, ny));
}
}
}
None
}
/// Find the closest traversable cell and return as world coordinates.
///
/// # Arguments
/// * `target` - Target point in world coordinates
/// * `max_radius_meters` - Maximum search radius in meters
pub fn find_closest_reachable_world(
&self,
target: WorldPoint,
max_radius_meters: f32,
) -> Option<WorldPoint> {
let target_coord = self.world_to_grid(target);
let max_radius_cells = (max_radius_meters / self.resolution).ceil() as i32;
self.find_closest_reachable_to(target_coord, max_radius_cells)
.map(|coord| self.grid_to_world(coord))
}
/// Check if line-of-sight is clear between two grid coordinates.
///
/// Uses Bresenham's line algorithm to check all cells along the line.
pub fn line_of_sight(&self, from: GridCoord, to: GridCoord) -> bool {
let mut x0 = from.x;
let mut y0 = from.y;
let x1 = to.x;
let y1 = to.y;
let dx = (x1 - x0).abs();
let dy = (y1 - y0).abs();
let sx = if x0 < x1 { 1 } else { -1 };
let sy = if y0 < y1 { 1 } else { -1 };
let mut err = dx - dy;
loop {
// Check current cell
if self.cost(GridCoord::new(x0, y0)) >= costs::INSCRIBED {
return false;
}
if x0 == x1 && y0 == y1 {
break;
}
let e2 = 2 * err;
if e2 > -dy {
err -= dy;
x0 += sx;
}
if e2 < dx {
err += dx;
y0 += sy;
}
}
true
}
/// Compute wall penalty along a line segment.
///
/// Returns the integrated penalty based on distance to walls.
pub fn integrate_wall_penalty(&self, from: GridCoord, to: GridCoord) -> f32 {
let mut penalty = 0.0;
let mut count = 0;
let mut x0 = from.x;
let mut y0 = from.y;
let x1 = to.x;
let y1 = to.y;
let dx = (x1 - x0).abs();
let dy = (y1 - y0).abs();
let sx = if x0 < x1 { 1 } else { -1 };
let sy = if y0 < y1 { 1 } else { -1 };
let mut err = dx - dy;
let penalty_dist = self.wall_penalty_cells as f32;
loop {
let coord = GridCoord::new(x0, y0);
let dist = self.obstacle_distance(coord);
if dist < penalty_dist {
penalty += (penalty_dist - dist) / penalty_dist;
}
count += 1;
if x0 == x1 && y0 == y1 {
break;
}
let e2 = 2 * err;
if e2 > -dy {
err -= dy;
x0 += sx;
}
if e2 < dx {
err += dx;
y0 += sy;
}
}
// Normalize by path length
if count > 0 {
penalty / count as f32
} else {
0.0
}
}
/// Convert world coordinates to grid coordinates.
#[inline]
pub fn world_to_grid(&self, point: WorldPoint) -> GridCoord {
let x = ((point.x - self.origin.x) / self.resolution).floor() as i32;
let y = ((point.y - self.origin.y) / self.resolution).floor() as i32;
GridCoord::new(x, y)
}
/// Convert grid coordinates to world coordinates (cell center).
#[inline]
pub fn grid_to_world(&self, coord: GridCoord) -> WorldPoint {
WorldPoint::new(
self.origin.x + (coord.x as f32 + 0.5) * self.resolution,
self.origin.y + (coord.y as f32 + 0.5) * self.resolution,
)
}
/// Get grid resolution in meters.
#[inline]
pub fn resolution(&self) -> f32 {
self.resolution
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_grid() -> GridStorage {
let mut grid = GridStorage::new(20, 20, 0.05, WorldPoint::ZERO);
// Add some walls
for x in 5..15 {
grid.set_type(GridCoord::new(x, 10), CellType::Wall);
}
// Mark rest as floor
for y in 0..20 {
for x in 0..20 {
let coord = GridCoord::new(x, y);
if grid.get_type(coord) == CellType::Unknown {
grid.set_type(coord, CellType::Floor);
}
}
}
grid
}
#[test]
fn test_cost_map_creation() {
let grid = create_test_grid();
let cost_map = CostMap::from_grid(&grid, 0.10, 0.05, 0.20);
// Wall cells should be lethal
assert_eq!(cost_map.cost(GridCoord::new(10, 10)), costs::LETHAL);
// Cells far from walls should be free or low cost
let far_cost = cost_map.cost(GridCoord::new(10, 0));
assert!(far_cost < costs::INSCRIBED);
}
#[test]
fn test_distance_field() {
let grid = create_test_grid();
let cost_map = CostMap::from_grid(&grid, 0.10, 0.05, 0.20);
// Wall cells have distance 0
assert_eq!(cost_map.obstacle_distance(GridCoord::new(10, 10)), 0.0);
// Adjacent cells have distance ~1
let adj_dist = cost_map.obstacle_distance(GridCoord::new(10, 9));
assert!(adj_dist > 0.9 && adj_dist < 1.1);
// Distant cells have larger distance
let far_dist = cost_map.obstacle_distance(GridCoord::new(10, 0));
assert!(far_dist > 5.0);
}
#[test]
fn test_line_of_sight() {
let grid = create_test_grid();
let cost_map = CostMap::from_grid(&grid, 0.10, 0.05, 0.20);
// Line that doesn't cross wall
assert!(cost_map.line_of_sight(GridCoord::new(0, 0), GridCoord::new(5, 5)));
// Line that crosses wall
assert!(!cost_map.line_of_sight(GridCoord::new(10, 5), GridCoord::new(10, 15)));
}
#[test]
fn test_traversability() {
let grid = create_test_grid();
let cost_map = CostMap::from_grid(&grid, 0.10, 0.05, 0.20);
// Wall is not traversable
assert!(!cost_map.is_traversable(GridCoord::new(10, 10)));
// Open space is traversable
assert!(cost_map.is_traversable(GridCoord::new(10, 0)));
}
}
+14
View File
@@ -0,0 +1,14 @@
//! Path planning module for autonomous navigation.
//!
//! This module provides:
//! - Cost map with obstacle inflation and wall distance penalties
//! - Theta* path planner for any-angle pathfinding
//! - Path smoother for differential drive kinematics
mod cost_map;
mod smoother;
mod theta_star;
pub use cost_map::CostMap;
pub use smoother::{PathSegment, PathSmoother, SmoothedPath};
pub use theta_star::ThetaStarPlanner;
+287
View File
@@ -0,0 +1,287 @@
//! Path smoother for differential drive kinematics.
//!
//! Post-processes Theta* paths to ensure smooth motion by:
//! - Inserting arc segments for medium turns
//! - Inserting point turns for sharp angles
//! - Respecting minimum turning radius constraints
use vastu_slam::WorldPoint;
use super::theta_star::PlannedPath;
/// Configuration for path smoothing.
#[derive(Clone, Debug)]
pub struct SmootherConfig {
/// Maximum linear velocity (m/s)
pub max_linear_vel: f32,
/// Maximum angular velocity (rad/s)
pub max_angular_vel: f32,
/// Angle threshold for inserting arcs (radians, ~30°)
pub arc_turn_threshold: f32,
/// Angle threshold for point turns (radians, ~90°)
pub sharp_turn_threshold: f32,
}
impl Default for SmootherConfig {
fn default() -> Self {
Self {
max_linear_vel: 0.2,
max_angular_vel: 0.5,
arc_turn_threshold: 0.52, // ~30 degrees
sharp_turn_threshold: 1.57, // ~90 degrees
}
}
}
impl SmootherConfig {
/// Compute minimum turning radius from velocity constraints.
pub fn min_turn_radius(&self) -> f32 {
self.max_linear_vel / self.max_angular_vel
}
}
/// Type of path segment.
#[derive(Clone, Debug)]
pub enum PathSegment {
/// Straight line segment
Line { start: WorldPoint, end: WorldPoint },
/// In-place rotation for sharp turns
PointTurn {
position: WorldPoint,
from_angle: f32,
to_angle: f32,
},
}
impl PathSegment {
/// Get the starting point of this segment.
pub fn start_point(&self) -> WorldPoint {
match self {
PathSegment::Line { start, .. } => *start,
PathSegment::PointTurn { position, .. } => *position,
}
}
/// Get the ending point of this segment.
pub fn end_point(&self) -> WorldPoint {
match self {
PathSegment::Line { end, .. } => *end,
PathSegment::PointTurn { position, .. } => *position,
}
}
/// Get the length of this segment in meters.
pub fn length(&self) -> f32 {
match self {
PathSegment::Line { start, end } => start.distance(end),
PathSegment::PointTurn { .. } => 0.0, // No linear distance
}
}
}
/// Smoothed path with segments.
#[derive(Clone, Debug)]
pub struct SmoothedPath {
/// Path segments (lines, arcs, point turns)
pub segments: Vec<PathSegment>,
/// Total path length in meters
pub total_length: f32,
/// Estimated traversal time in seconds
pub estimated_time: f32,
}
/// Path smoother for differential drive robots.
pub struct PathSmoother {
config: SmootherConfig,
}
impl PathSmoother {
/// Create a new path smoother with configuration.
pub fn new(config: SmootherConfig) -> Self {
Self { config }
}
/// Create a new path smoother with default configuration.
pub fn with_defaults() -> Self {
Self::new(SmootherConfig::default())
}
/// Smooth a planned path for differential drive motion.
pub fn smooth(&self, path: &PlannedPath) -> SmoothedPath {
if path.waypoints.len() < 2 {
return SmoothedPath {
segments: Vec::new(),
total_length: 0.0,
estimated_time: 0.0,
};
}
let mut segments = Vec::new();
let _min_radius = self.config.min_turn_radius();
// Process waypoints and analyze turn angles
for i in 0..path.waypoints.len() - 1 {
let current = path.waypoints[i];
let next = path.waypoints[i + 1];
// Compute incoming and outgoing headings
let incoming_heading = if i > 0 {
let prev = path.waypoints[i - 1];
(current.y - prev.y).atan2(current.x - prev.x)
} else {
(next.y - current.y).atan2(next.x - current.x)
};
let outgoing_heading = (next.y - current.y).atan2(next.x - current.x);
// Compute turn angle
let turn_angle = Self::normalize_angle(outgoing_heading - incoming_heading);
let turn_angle_abs = turn_angle.abs();
// Decide what type of segment to create
if i > 0 && turn_angle_abs > self.config.sharp_turn_threshold {
// Sharp turn: insert point turn
segments.push(PathSegment::PointTurn {
position: current,
from_angle: incoming_heading,
to_angle: outgoing_heading,
});
} else if i > 0 && turn_angle_abs > self.config.arc_turn_threshold {
// Medium turn: could insert arc, but for simplicity use point turn
// Full arc implementation requires more complex geometry
segments.push(PathSegment::PointTurn {
position: current,
from_angle: incoming_heading,
to_angle: outgoing_heading,
});
}
// Add line segment
segments.push(PathSegment::Line {
start: current,
end: next,
});
}
// Compute totals
let total_length: f32 = segments.iter().map(|s| s.length()).sum();
let estimated_time = self.estimate_traversal_time(&segments);
SmoothedPath {
segments,
total_length,
estimated_time,
}
}
/// Estimate traversal time for the path.
fn estimate_traversal_time(&self, segments: &[PathSegment]) -> f32 {
let mut time = 0.0;
for segment in segments {
match segment {
PathSegment::Line { start, end } => {
let distance = start.distance(end);
time += distance / self.config.max_linear_vel;
}
PathSegment::PointTurn {
from_angle,
to_angle,
..
} => {
let angle_diff = Self::normalize_angle(to_angle - from_angle).abs();
time += angle_diff / self.config.max_angular_vel;
}
}
}
time
}
/// Normalize angle to [-pi, pi].
#[inline]
fn normalize_angle(angle: f32) -> f32 {
let mut a = angle;
while a > std::f32::consts::PI {
a -= 2.0 * std::f32::consts::PI;
}
while a < -std::f32::consts::PI {
a += 2.0 * std::f32::consts::PI;
}
a
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_path() -> PlannedPath {
PlannedPath {
waypoints: vec![
WorldPoint::new(0.0, 0.0),
WorldPoint::new(1.0, 0.0),
WorldPoint::new(1.0, 1.0),
WorldPoint::new(0.0, 1.0),
],
length: 3.0,
}
}
#[test]
fn test_smooth_simple_path() {
let path = create_test_path();
let smoother = PathSmoother::with_defaults();
let smoothed = smoother.smooth(&path);
// Should have line segments and point turns
assert!(!smoothed.segments.is_empty());
assert!(smoothed.total_length > 0.0);
assert!(smoothed.estimated_time > 0.0);
}
#[test]
fn test_sharp_turn_detection() {
let path = PlannedPath {
waypoints: vec![
WorldPoint::new(0.0, 0.0),
WorldPoint::new(1.0, 0.0),
WorldPoint::new(1.0, 1.0), // 90 degree turn
],
length: 2.0,
};
let smoother = PathSmoother::with_defaults();
let smoothed = smoother.smooth(&path);
// Should detect the 90 degree turn
let has_point_turn = smoothed
.segments
.iter()
.any(|s| matches!(s, PathSegment::PointTurn { .. }));
assert!(has_point_turn);
}
#[test]
fn test_straight_path_no_turns() {
let path = PlannedPath {
waypoints: vec![
WorldPoint::new(0.0, 0.0),
WorldPoint::new(1.0, 0.0),
WorldPoint::new(2.0, 0.0),
],
length: 2.0,
};
let smoother = PathSmoother::with_defaults();
let smoothed = smoother.smooth(&path);
// Should only have line segments
let all_lines = smoothed
.segments
.iter()
.all(|s| matches!(s, PathSegment::Line { .. }));
assert!(all_lines);
}
}
+422
View File
@@ -0,0 +1,422 @@
//! Theta* path planner for any-angle pathfinding.
//!
//! Theta* extends A* with line-of-sight checks to produce smoother,
//! shorter paths that aren't constrained to grid directions.
use std::cmp::Ordering;
use std::collections::{BinaryHeap, HashMap};
use vastu_slam::{GridCoord, Pose2D, WorldPoint};
use super::cost_map::{CostMap, costs};
/// Configuration for the Theta* path planner.
#[derive(Clone, Debug)]
pub struct ThetaStarConfig {
/// Maximum iterations before giving up
pub max_iterations: usize,
/// Weight for wall proximity penalty in path cost
pub wall_penalty_weight: f32,
/// Goal tolerance in cells
pub goal_tolerance: i32,
}
impl Default for ThetaStarConfig {
fn default() -> Self {
Self {
max_iterations: 50000,
wall_penalty_weight: 2.0,
goal_tolerance: 2,
}
}
}
/// Result of path planning.
#[derive(Clone, Debug)]
pub struct PlannedPath {
/// Waypoints in world coordinates
pub waypoints: Vec<WorldPoint>,
/// Total path length in meters
pub length: f32,
}
/// Node in the search graph.
#[derive(Clone, Debug)]
struct SearchNode {
coord: GridCoord,
f_score: f32,
}
impl PartialEq for SearchNode {
fn eq(&self, other: &Self) -> bool {
self.coord == other.coord
}
}
impl Eq for SearchNode {}
impl Ord for SearchNode {
fn cmp(&self, other: &Self) -> Ordering {
// Reverse ordering for min-heap (lower f_score = higher priority)
other
.f_score
.partial_cmp(&self.f_score)
.unwrap_or(Ordering::Equal)
}
}
impl PartialOrd for SearchNode {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
/// Theta* path planner.
pub struct ThetaStarPlanner {
config: ThetaStarConfig,
}
impl ThetaStarPlanner {
/// Create a new Theta* planner with configuration.
pub fn new(config: ThetaStarConfig) -> Self {
Self { config }
}
/// Create a new Theta* planner with default configuration.
pub fn with_defaults() -> Self {
Self::new(ThetaStarConfig::default())
}
/// Plan a path from start pose to goal point.
///
/// Returns `None` if no valid path exists.
pub fn plan(&self, cost_map: &CostMap, start: Pose2D, goal: WorldPoint) -> Option<PlannedPath> {
let start_coord = cost_map.world_to_grid(WorldPoint::new(start.x, start.y));
let goal_coord = cost_map.world_to_grid(goal);
// Check if start is valid, if not find nearest traversable cell
let actual_start = if !cost_map.is_traversable(start_coord) {
tracing::warn!("Start position is not traversable, searching for nearby valid cell");
if let Some(valid_start) = Self::find_nearest_traversable(cost_map, start_coord, 10) {
valid_start
} else {
tracing::warn!("No traversable cell found near start position");
return None;
}
} else {
start_coord
};
// Check if goal is valid
// Note: We don't use find_nearest_traversable for the goal because if the specified
// goal is blocked, we should fail explicitly rather than silently redirecting to a
// different location. The explorer/caller should handle goal validation.
if !cost_map.is_traversable(goal_coord) {
tracing::warn!(
"Goal position ({}, {}) is not traversable",
goal_coord.x,
goal_coord.y
);
return None;
}
let actual_goal = goal_coord;
// Run Theta* search
let path_coords = self.theta_star_search(cost_map, actual_start, actual_goal)?;
// Convert to world coordinates and compute statistics
let waypoints: Vec<WorldPoint> = path_coords
.iter()
.map(|&coord| cost_map.grid_to_world(coord))
.collect();
let length = self.compute_path_length(&waypoints);
Some(PlannedPath { waypoints, length })
}
/// Theta* search algorithm.
fn theta_star_search(
&self,
cost_map: &CostMap,
start: GridCoord,
goal: GridCoord,
) -> Option<Vec<GridCoord>> {
let mut open_set = BinaryHeap::new();
let mut g_score: HashMap<GridCoord, f32> = HashMap::new();
let mut parent: HashMap<GridCoord, GridCoord> = HashMap::new();
let mut closed_set: HashMap<GridCoord, bool> = HashMap::new();
// Initialize start node
g_score.insert(start, 0.0);
parent.insert(start, start); // Start is its own parent
open_set.push(SearchNode {
coord: start,
f_score: Self::heuristic(start, goal),
});
let mut iterations = 0;
// 8-connected neighbors
let neighbors = [
GridCoord::new(-1, 0),
GridCoord::new(1, 0),
GridCoord::new(0, -1),
GridCoord::new(0, 1),
GridCoord::new(-1, -1),
GridCoord::new(1, -1),
GridCoord::new(-1, 1),
GridCoord::new(1, 1),
];
while let Some(current_node) = open_set.pop() {
iterations += 1;
if iterations > self.config.max_iterations {
tracing::warn!("Theta* exceeded max iterations");
return None;
}
let current = current_node.coord;
// Check if we've reached the goal (with tolerance)
if current.manhattan_distance(&goal) <= self.config.goal_tolerance {
return Some(self.reconstruct_path(&parent, current));
}
// Skip if already processed
if closed_set.contains_key(&current) {
continue;
}
closed_set.insert(current, true);
let current_g = *g_score.get(&current).unwrap_or(&f32::MAX);
let current_parent = *parent.get(&current).unwrap_or(&current);
// Explore neighbors
for &offset in &neighbors {
let neighbor = GridCoord::new(current.x + offset.x, current.y + offset.y);
// Skip if not traversable
if cost_map.cost(neighbor) >= costs::INSCRIBED {
continue;
}
// Skip if already processed
if closed_set.contains_key(&neighbor) {
continue;
}
// Theta* key insight: check line-of-sight to grandparent
let (new_g, new_parent) = if current_parent != current
&& cost_map.line_of_sight(current_parent, neighbor)
{
// Path 2: Direct path from grandparent (any-angle)
let parent_g = *g_score.get(&current_parent).unwrap_or(&f32::MAX);
let edge_cost = self.path_cost(cost_map, current_parent, neighbor);
(parent_g + edge_cost, current_parent)
} else {
// Path 1: Through current node (grid-constrained)
let edge_cost = self.path_cost(cost_map, current, neighbor);
(current_g + edge_cost, current)
};
let existing_g = *g_score.get(&neighbor).unwrap_or(&f32::MAX);
if new_g < existing_g {
g_score.insert(neighbor, new_g);
parent.insert(neighbor, new_parent);
let f_score = new_g + Self::heuristic(neighbor, goal);
open_set.push(SearchNode {
coord: neighbor,
f_score,
});
}
}
}
// No path found
None
}
/// Compute heuristic (Euclidean distance).
#[inline]
fn heuristic(from: GridCoord, to: GridCoord) -> f32 {
let dx = (to.x - from.x) as f32;
let dy = (to.y - from.y) as f32;
(dx * dx + dy * dy).sqrt()
}
/// Compute path cost between two points including wall penalty.
fn path_cost(&self, cost_map: &CostMap, from: GridCoord, to: GridCoord) -> f32 {
let dx = (to.x - from.x) as f32;
let dy = (to.y - from.y) as f32;
let distance = (dx * dx + dy * dy).sqrt();
let wall_penalty = cost_map.integrate_wall_penalty(from, to);
distance + self.config.wall_penalty_weight * wall_penalty * distance
}
/// Reconstruct path from parent map.
fn reconstruct_path(
&self,
parent: &HashMap<GridCoord, GridCoord>,
goal: GridCoord,
) -> Vec<GridCoord> {
let mut path = Vec::new();
let mut current = goal;
loop {
path.push(current);
match parent.get(&current) {
Some(&p) if p != current => current = p,
_ => break,
}
}
path.reverse();
path
}
/// Compute path length.
fn compute_path_length(&self, waypoints: &[WorldPoint]) -> f32 {
if waypoints.len() < 2 {
return 0.0;
}
let mut length = 0.0;
for i in 0..waypoints.len() - 1 {
let from = waypoints[i];
let to = waypoints[i + 1];
let dx = to.x - from.x;
let dy = to.y - from.y;
length += (dx * dx + dy * dy).sqrt();
}
length
}
/// Find the nearest traversable cell to a given coordinate.
/// Uses BFS to search in expanding rings around the target.
fn find_nearest_traversable(
cost_map: &CostMap,
center: GridCoord,
max_radius: i32,
) -> Option<GridCoord> {
// Check center first
if cost_map.is_traversable(center) {
return Some(center);
}
// Search in expanding rings
for r in 1..=max_radius {
// Check all cells at distance r (Manhattan-ish)
for dx in -r..=r {
for dy in -r..=r {
// Only check cells on the "ring" boundary
if dx.abs() != r && dy.abs() != r {
continue;
}
let coord = GridCoord::new(center.x + dx, center.y + dy);
if cost_map.is_traversable(coord) {
return Some(coord);
}
}
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use vastu_slam::{CellType, GridStorage};
fn create_test_grid() -> GridStorage {
let mut grid = GridStorage::new(50, 50, 0.05, WorldPoint::ZERO);
// Mark all as floor
for y in 0..50 {
for x in 0..50 {
grid.set_type(GridCoord::new(x, y), CellType::Floor);
}
}
// Add a wall in the middle
for x in 10..40 {
grid.set_type(GridCoord::new(x, 25), CellType::Wall);
}
grid
}
#[test]
fn test_simple_path() {
let grid = create_test_grid();
let cost_map = CostMap::from_grid(&grid, 0.10, 0.05, 0.20);
let planner = ThetaStarPlanner::with_defaults();
let start = Pose2D::new(0.5, 0.5, 0.0);
let goal = WorldPoint::new(2.0, 0.5);
let path = planner.plan(&cost_map, start, goal);
assert!(path.is_some());
let path = path.unwrap();
assert!(path.waypoints.len() >= 2);
assert!(path.length > 0.0);
}
#[test]
fn test_path_around_obstacle() {
let grid = create_test_grid();
let cost_map = CostMap::from_grid(&grid, 0.10, 0.05, 0.20);
let planner = ThetaStarPlanner::with_defaults();
// Path that needs to go around the wall
let start = Pose2D::new(1.25, 1.0, 0.0);
let goal = WorldPoint::new(1.25, 1.5);
let path = planner.plan(&cost_map, start, goal);
assert!(path.is_some());
let path = path.unwrap();
// Path should go around, so it will be longer than direct line
assert!(path.length > 0.5);
}
#[test]
fn test_no_path_to_blocked_goal() {
let mut grid = GridStorage::new(20, 20, 0.05, WorldPoint::ZERO);
// Create an enclosed area
for y in 0..20 {
for x in 0..20 {
grid.set_type(GridCoord::new(x, y), CellType::Floor);
}
}
// Surround center with walls
for x in 8..12 {
grid.set_type(GridCoord::new(x, 8), CellType::Wall);
grid.set_type(GridCoord::new(x, 12), CellType::Wall);
}
for y in 8..12 {
grid.set_type(GridCoord::new(8, y), CellType::Wall);
grid.set_type(GridCoord::new(12, y), CellType::Wall);
}
let cost_map = CostMap::from_grid(&grid, 0.10, 0.05, 0.20);
let planner = ThetaStarPlanner::with_defaults();
let start = Pose2D::new(0.25, 0.25, 0.0);
let goal = WorldPoint::new(0.5, 0.5); // Inside enclosed area
let path = planner.plan(&cost_map, start, goal);
assert!(path.is_none());
}
}
+275
View File
@@ -0,0 +1,275 @@
//! Shared state for multi-threaded architecture.
//!
//! Provides thread-safe shared state between:
//! - Sensor thread (UDP reading, odometry, safety, commands)
//! - Mapping thread (scan matching, map updates)
//! - Exploration thread (frontier detection, path planning)
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, RwLock};
use vastu_slam::{GridStorage, Pose2D};
use crate::utils::normalize_angle;
/// Atomic wrapper for f32 values.
/// Uses AtomicU32 with bit reinterpretation.
#[derive(Debug)]
pub struct AtomicF32(AtomicU32);
impl AtomicF32 {
pub fn new(val: f32) -> Self {
Self(AtomicU32::new(val.to_bits()))
}
pub fn load(&self, order: Ordering) -> f32 {
f32::from_bits(self.0.load(order))
}
pub fn store(&self, val: f32, order: Ordering) {
self.0.store(val.to_bits(), order);
}
}
/// Atomic wrapper for Pose2D.
/// Packs x, y, theta into atomic u64 values for lock-free access.
/// Uses fixed-point representation: 16-bit integer + 16-bit fraction per component.
#[derive(Debug)]
pub struct AtomicPose {
// Pack x and y into one u64 (each as i32 in mm for precision)
xy: AtomicU64,
// Theta as fixed-point (multiplied by 10000)
theta: AtomicU32,
}
impl AtomicPose {
pub fn new(pose: Pose2D) -> Self {
let x_mm = (pose.x * 1000.0) as i32;
let y_mm = (pose.y * 1000.0) as i32;
let xy = ((x_mm as u64) << 32) | (y_mm as u32 as u64);
let theta = (pose.theta * 10000.0) as i32 as u32;
Self {
xy: AtomicU64::new(xy),
theta: AtomicU32::new(theta),
}
}
pub fn load(&self, order: Ordering) -> Pose2D {
let xy = self.xy.load(order);
let x_mm = (xy >> 32) as i32;
let y_mm = xy as i32;
let theta_fp = self.theta.load(order) as i32;
Pose2D::new(
x_mm as f32 / 1000.0,
y_mm as f32 / 1000.0,
theta_fp as f32 / 10000.0,
)
}
pub fn store(&self, pose: Pose2D, order: Ordering) {
let x_mm = (pose.x * 1000.0) as i32;
let y_mm = (pose.y * 1000.0) as i32;
let xy = ((x_mm as u64) << 32) | (y_mm as u32 as u64);
let theta = (pose.theta * 10000.0) as i32 as u32;
self.xy.store(xy, order);
self.theta.store(theta, order);
}
}
/// Shared state between all threads.
#[derive(Debug)]
pub struct SharedState {
/// Last corrected pose from scan matching (updated by mapping thread)
corrected_pose: AtomicPose,
/// Raw odometry pose at the time of last correction
odometry_at_correction: AtomicPose,
/// Current raw odometry pose (updated by sensor thread)
current_odometry: AtomicPose,
/// Velocity command: linear (m/s)
pub linear_vel: AtomicF32,
/// Velocity command: angular (rad/s)
pub angular_vel: AtomicF32,
/// Safety stop flag
pub safety_stop: AtomicBool,
/// Safety stop reason (if any)
pub safety_reason: RwLock<Option<String>>,
/// Exploration complete flag
pub exploration_complete: AtomicBool,
/// Shutdown signal for graceful termination
pub shutdown: AtomicBool,
/// Number of scans processed (for status reporting)
pub scan_count: AtomicU32,
/// Number of frontiers remaining
pub frontiers_remaining: AtomicU32,
}
impl SharedState {
/// Create new shared state with initial pose.
pub fn new(initial_pose: Pose2D) -> Self {
Self {
corrected_pose: AtomicPose::new(initial_pose),
odometry_at_correction: AtomicPose::new(initial_pose),
current_odometry: AtomicPose::new(initial_pose),
linear_vel: AtomicF32::new(0.0),
angular_vel: AtomicF32::new(0.0),
safety_stop: AtomicBool::new(false),
safety_reason: RwLock::new(None),
exploration_complete: AtomicBool::new(false),
shutdown: AtomicBool::new(false),
scan_count: AtomicU32::new(0),
frontiers_remaining: AtomicU32::new(0),
}
}
/// Get current pose (corrected pose + odometry delta since last correction).
///
/// This provides a smooth, frequently-updated pose that incorporates:
/// - Scan matching corrections from the mapping thread (5Hz)
/// - Odometry updates from the sensor thread (110Hz)
pub fn pose(&self) -> Pose2D {
let corrected = self.corrected_pose.load(Ordering::Acquire);
let odom_at_correction = self.odometry_at_correction.load(Ordering::Acquire);
let current_odom = self.current_odometry.load(Ordering::Acquire);
// Compute odometry delta since last correction
let dx = current_odom.x - odom_at_correction.x;
let dy = current_odom.y - odom_at_correction.y;
// Normalize dtheta to handle wraparound (e.g., -179° to +179° = 2°, not 358°)
let dtheta = normalize_angle(current_odom.theta - odom_at_correction.theta);
// Apply delta to corrected pose (normalize final theta to [-π, π])
Pose2D::new(
corrected.x + dx,
corrected.y + dy,
normalize_angle(corrected.theta + dtheta),
)
}
/// Update raw odometry pose (called by sensor thread at 110Hz).
pub fn set_odometry(&self, pose: Pose2D) {
self.current_odometry.store(pose, Ordering::Release);
}
/// Apply scan matching correction (called by mapping thread at 5Hz).
///
/// This updates the corrected pose and records the odometry at correction time,
/// so future pose() calls can apply the correct delta.
pub fn apply_correction(&self, corrected: Pose2D, odometry_at_scan: Pose2D) {
self.corrected_pose.store(corrected, Ordering::Release);
self.odometry_at_correction
.store(odometry_at_scan, Ordering::Release);
}
/// Get velocity command (linear, angular).
pub fn velocity(&self) -> (f32, f32) {
(
self.linear_vel.load(Ordering::Acquire),
self.angular_vel.load(Ordering::Acquire),
)
}
/// Set velocity command.
pub fn set_velocity(&self, linear: f32, angular: f32) {
self.linear_vel.store(linear, Ordering::Release);
self.angular_vel.store(angular, Ordering::Release);
}
/// Trigger safety stop with reason.
pub fn trigger_safety_stop(&self, reason: String) {
if let Ok(mut guard) = self.safety_reason.write() {
*guard = Some(reason);
}
self.safety_stop.store(true, Ordering::Release);
}
/// Check if safety stop is triggered.
pub fn is_safety_stop(&self) -> bool {
self.safety_stop.load(Ordering::Acquire)
}
/// Get safety stop reason.
pub fn safety_reason(&self) -> Option<String> {
self.safety_reason.read().ok().and_then(|g| g.clone())
}
/// Signal shutdown.
pub fn signal_shutdown(&self) {
self.shutdown.store(true, Ordering::Release);
}
/// Check if shutdown is signaled.
pub fn should_shutdown(&self) -> bool {
self.shutdown.load(Ordering::Acquire)
}
/// Mark exploration as complete.
pub fn set_exploration_complete(&self) {
self.exploration_complete.store(true, Ordering::Release);
}
/// Check if exploration is complete.
pub fn is_exploration_complete(&self) -> bool {
self.exploration_complete.load(Ordering::Acquire)
}
/// Increment scan count.
pub fn increment_scan_count(&self) {
self.scan_count.fetch_add(1, Ordering::Relaxed);
}
/// Get scan count.
pub fn scan_count(&self) -> u32 {
self.scan_count.load(Ordering::Relaxed)
}
/// Set frontiers remaining.
pub fn set_frontiers_remaining(&self, count: usize) {
self.frontiers_remaining
.store(count as u32, Ordering::Relaxed);
}
}
/// Thread-safe map storage wrapper.
pub type SharedGrid = Arc<RwLock<GridStorage>>;
/// Thread-safe trajectory storage for SVG visualization.
pub type SharedTrajectory = Arc<RwLock<Vec<Pose2D>>>;
/// Debug visualization data for SVG output.
#[derive(Clone, Debug, Default)]
pub struct DebugVisualization {
/// Frontier centroids that were visited
pub visited_frontiers: Vec<vastu_slam::WorldPoint>,
/// Waypoints from planned paths
pub waypoints: Vec<vastu_slam::WorldPoint>,
}
/// Thread-safe debug visualization data.
pub type SharedDebugViz = Arc<RwLock<DebugVisualization>>;
/// Message types for inter-thread communication.
pub mod messages {
use super::*;
/// Lidar scan data sent from sensor thread to mapping thread.
#[derive(Clone)]
pub struct LidarScanMsg {
/// Lidar points: (angle_rad, distance_m, quality)
pub points: Vec<(f32, f32, u8)>,
/// Encoder-based pose at scan time
pub encoder_pose: Pose2D,
}
}
+254
View File
@@ -0,0 +1,254 @@
//! Exploration thread: Frontier detection and path planning.
//!
//! This thread reads from the shared grid and:
//! - Detects frontiers (boundaries between known and unknown space)
//! - Plans paths to frontiers using Theta*
//! - Follows paths using pure pursuit
//! - Updates velocity commands in shared state
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::config::DhruvaConfig;
use crate::exploration::{
ExplorationState, Explorer, ExplorerConfig, FollowerConfig, FrontierConfig,
};
use crate::shared::{SharedDebugViz, SharedGrid, SharedState};
/// Exploration thread state and logic.
pub struct ExplorationThread {
shared_state: Arc<SharedState>,
shared_grid: SharedGrid,
shared_debug_viz: SharedDebugViz,
explorer: Explorer,
last_status_time: Instant,
status_interval: Duration,
started: bool,
min_scans_to_start: u32,
}
impl ExplorationThread {
/// Create a new exploration thread.
pub fn new(
config: DhruvaConfig,
shared_state: Arc<SharedState>,
shared_grid: SharedGrid,
shared_debug_viz: SharedDebugViz,
) -> Self {
// Configure exploration from config
let explorer_config = ExplorerConfig {
frontier: FrontierConfig {
min_cluster_size: config.exploration.min_frontier_size,
..Default::default()
},
follower: FollowerConfig {
goal_tolerance: config.exploration.goal_tolerance,
max_linear_vel: config.robot.max_linear_vel,
max_angular_vel: config.robot.max_angular_vel,
..Default::default()
},
robot_radius: config.exploration.robot_radius,
safety_margin: config.exploration.safety_margin,
wall_penalty_distance: config.exploration.wall_penalty_distance,
frontier_refresh_interval: config.exploration.frontier_refresh_interval,
return_to_start: config.exploration.return_to_start,
// Recovery and stuck detection parameters
max_replans_per_frontier: config.exploration.max_replans_per_frontier,
frontier_timeout_secs: config.exploration.frontier_timeout_secs,
min_progress_distance: config.exploration.min_progress_distance,
blacklist_cooldown_secs: config.exploration.blacklist_cooldown_secs,
stuck_distance_threshold: config.exploration.stuck_distance_threshold,
stuck_time_window_secs: config.exploration.stuck_time_window_secs,
recovery_backup_distance: config.exploration.recovery_backup_distance,
recovery_rotation_rad: config.exploration.recovery_rotation_rad,
max_recovery_attempts: config.exploration.max_recovery_attempts,
..Default::default()
};
Self {
shared_state,
shared_grid,
shared_debug_viz,
explorer: Explorer::new(explorer_config),
last_status_time: Instant::now(),
status_interval: Duration::from_secs(3),
started: false,
min_scans_to_start: 10,
}
}
/// Run the exploration thread main loop.
pub fn run(&mut self) {
tracing::info!("Exploration thread started");
// Target ~20Hz for exploration updates
let loop_interval = Duration::from_millis(50);
loop {
let loop_start = Instant::now();
// Check for shutdown
if self.shared_state.should_shutdown() {
tracing::info!("Exploration thread shutting down");
break;
}
// Check for safety stop - set velocity to zero
if self.shared_state.is_safety_stop() {
self.shared_state.set_velocity(0.0, 0.0);
std::thread::sleep(Duration::from_millis(100));
continue;
}
// Wait for enough scans before starting exploration
if !self.started {
if self.shared_state.scan_count() < self.min_scans_to_start {
std::thread::sleep(Duration::from_millis(100));
continue;
}
// Start exploration
let current_pose = self.shared_state.pose();
self.explorer.start(current_pose);
self.started = true;
tracing::info!(
"Exploration starting after {} scans",
self.min_scans_to_start
);
}
// Get current pose
let current_pose = self.shared_state.pose();
// Read grid snapshot
let grid = match self.shared_grid.read() {
Ok(g) => g.clone(), // Clone to release lock quickly
Err(e) => {
tracing::error!("Failed to read grid: {}", e);
std::thread::sleep(loop_interval);
continue;
}
};
// Update exploration
let step = self.explorer.update(&grid, current_pose);
// Update shared state
self.shared_state
.set_velocity(step.velocity.0, step.velocity.1);
self.shared_state
.set_frontiers_remaining(step.frontiers_remaining);
// Update debug visualization with frontier and waypoint data
self.update_debug_viz(&step);
// Check if exploration is complete
if self.explorer.is_complete() {
match step.state {
ExplorationState::Complete => {
tracing::info!("Exploration complete!");
}
ExplorationState::Failed => {
tracing::warn!("Exploration failed: no reachable frontiers");
}
_ => {}
}
self.shared_state.set_velocity(0.0, 0.0);
self.shared_state.set_exploration_complete();
break;
}
// Log status periodically
if self.last_status_time.elapsed() >= self.status_interval {
self.log_status(&step, current_pose);
self.last_status_time = Instant::now();
}
// Maintain target loop rate
let elapsed = loop_start.elapsed();
if elapsed < loop_interval {
std::thread::sleep(loop_interval - elapsed);
}
}
tracing::info!("Exploration thread exited");
}
/// Log exploration status.
fn log_status(&self, step: &crate::exploration::ExplorationStep, pose: vastu_slam::Pose2D) {
let scans = self.shared_state.scan_count();
let state_str = match step.state {
ExplorationState::Idle => "Idle",
ExplorationState::SelectingFrontier => "Selecting frontier",
ExplorationState::Planning => "Planning path",
ExplorationState::Navigating => "Navigating",
ExplorationState::Replanning => "Replanning",
ExplorationState::Recovering => "Recovering",
ExplorationState::EscapingToRetry => "Escaping to retry",
ExplorationState::ReturningHome => "Returning home",
ExplorationState::Complete => "Complete",
ExplorationState::Failed => "Failed",
};
tracing::info!(
"Exploring: state={}, frontiers={}, pose=({:.2}, {:.2}, {:.1}°), scans={}",
state_str,
step.frontiers_remaining,
pose.x,
pose.y,
pose.theta.to_degrees(),
scans
);
if let Some(target) = step.target_frontier {
tracing::debug!("Target frontier: ({:.2}, {:.2})", target.x, target.y);
}
}
/// Update debug visualization data for SVG output.
fn update_debug_viz(&self, step: &crate::exploration::ExplorationStep) {
// Record frontier targets when navigating
if let Some(target) = step.target_frontier
&& let Ok(mut debug_viz) = self.shared_debug_viz.write()
{
// Add target frontier if not already recorded
let target_point = vastu_slam::WorldPoint::new(target.x, target.y);
if !debug_viz
.visited_frontiers
.iter()
.any(|p| (p.x - target_point.x).abs() < 0.1 && (p.y - target_point.y).abs() < 0.1)
{
debug_viz.visited_frontiers.push(target_point);
}
}
// Record waypoints from the current path
if let Some(path) = self.explorer.current_path()
&& let Ok(mut debug_viz) = self.shared_debug_viz.write()
{
for segment in &path.segments {
let start = segment.start_point();
let end = segment.end_point();
// Add start point if not already recorded (avoiding duplicates)
let start_wp = vastu_slam::WorldPoint::new(start.x, start.y);
if !debug_viz
.waypoints
.iter()
.any(|p| (p.x - start_wp.x).abs() < 0.05 && (p.y - start_wp.y).abs() < 0.05)
{
debug_viz.waypoints.push(start_wp);
}
// Add end point
let end_wp = vastu_slam::WorldPoint::new(end.x, end.y);
if !debug_viz
.waypoints
.iter()
.any(|p| (p.x - end_wp.x).abs() < 0.05 && (p.y - end_wp.y).abs() < 0.05)
{
debug_viz.waypoints.push(end_wp);
}
}
}
}
}
+174
View File
@@ -0,0 +1,174 @@
//! Mapping thread: Scan matching and map updates.
//!
//! This thread receives lidar scans from the sensor thread and:
//! - Performs scan matching for pose correction
//! - Updates the occupancy grid map
//! - Copies the grid to shared storage for exploration
use std::sync::Arc;
use std::sync::mpsc::Receiver;
use std::time::{Duration, Instant};
use vastu_slam::matching::CorrelativeMatcherConfig;
use vastu_slam::{LidarScan, MapConfig, OccupancyGridMap, Pose2D};
use crate::shared::{SharedGrid, SharedState, SharedTrajectory, messages::LidarScanMsg};
/// Mapping thread state and logic.
pub struct MappingThread {
shared_state: Arc<SharedState>,
shared_grid: SharedGrid,
shared_trajectory: SharedTrajectory,
lidar_rx: Receiver<LidarScanMsg>,
map: OccupancyGridMap,
matcher_config: CorrelativeMatcherConfig,
trajectory: Vec<Pose2D>,
scan_count: usize,
min_match_score: f32,
last_grid_sync: Instant,
grid_sync_interval: Duration,
}
impl MappingThread {
/// Create a new mapping thread.
pub fn new(
shared_state: Arc<SharedState>,
shared_grid: SharedGrid,
shared_trajectory: SharedTrajectory,
lidar_rx: Receiver<LidarScanMsg>,
) -> Self {
// Initialize map with default configuration
let map_config = MapConfig::default();
// Use a balanced matcher config: better accuracy than fast() but still real-time
// The fast() config (3cm resolution, 180 points) causes map smearing
let matcher_config = CorrelativeMatcherConfig {
search_x: 0.20, // 20cm search window
search_y: 0.20, // 20cm search window
search_theta: 0.20, // ~11 degrees
linear_resolution: 0.02, // 2cm steps (finer than fast's 3cm)
angular_resolution: 0.02, // ~1.1 degrees (finer than fast's 3cm)
max_points: 0, // Use all points for better accuracy
// Note: sensor_offset is (0,0) - SangamIO now sends lidar data
// already transformed to robot center coordinates
sensor_offset: (0.0, 0.0),
..CorrelativeMatcherConfig::default()
};
Self {
shared_state,
shared_grid,
shared_trajectory,
lidar_rx,
map: OccupancyGridMap::new(map_config),
matcher_config,
trajectory: Vec::new(),
scan_count: 0,
min_match_score: 0.55, // Slightly higher threshold
last_grid_sync: Instant::now(),
grid_sync_interval: Duration::from_millis(100), // Sync grid every 100ms
}
}
/// Run the mapping thread main loop.
pub fn run(&mut self) {
tracing::info!("Mapping thread started");
loop {
// Check for shutdown
if self.shared_state.should_shutdown() {
tracing::info!("Mapping thread shutting down");
// Final grid sync before shutdown
self.sync_grid();
break;
}
// Receive lidar scan with timeout
match self.lidar_rx.recv_timeout(Duration::from_millis(100)) {
Ok(scan_msg) => {
self.process_scan(scan_msg);
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
// No scan available, sync grid if needed
if self.last_grid_sync.elapsed() >= self.grid_sync_interval {
self.sync_grid();
}
continue;
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
tracing::warn!("Lidar channel disconnected, mapping thread exiting");
break;
}
}
// Sync grid periodically
if self.last_grid_sync.elapsed() >= self.grid_sync_interval {
self.sync_grid();
}
}
}
/// Process a lidar scan.
fn process_scan(&mut self, scan_msg: LidarScanMsg) {
let scan = to_lidar_scan(&scan_msg.points);
let encoder_pose = scan_msg.encoder_pose;
self.scan_count += 1;
self.shared_state.increment_scan_count();
// For the first few scans, seed the map without matching
let corrected_pose = if self.scan_count <= 5 {
self.map.observe_lidar(&scan, encoder_pose);
encoder_pose
} else {
// Use scan matching for pose correction
let (_result, corrected, match_result) =
self.map
.observe_lidar_with_matching(&scan, encoder_pose, &self.matcher_config);
if match_result.converged && match_result.score >= self.min_match_score {
tracing::trace!(
"Scan match accepted: score={:.3}, pose=({:.3}, {:.3})",
match_result.score,
corrected.x,
corrected.y
);
corrected
} else {
tracing::debug!(
"Scan match rejected: score={:.3}, converged={}",
match_result.score,
match_result.converged
);
encoder_pose
}
};
// Apply correction: store corrected pose and the odometry at correction time
// This allows pose() to apply odometry deltas between corrections
self.shared_state
.apply_correction(corrected_pose, encoder_pose);
self.trajectory.push(corrected_pose);
// Push to shared trajectory for SVG visualization
if let Ok(mut traj) = self.shared_trajectory.write() {
traj.push(corrected_pose);
}
}
/// Sync the internal grid to shared storage.
fn sync_grid(&mut self) {
// Clone the grid storage and update shared state
if let Ok(mut grid) = self.shared_grid.write() {
*grid = self.map.storage().clone();
}
self.last_grid_sync = Instant::now();
}
}
/// Convert lidar points to VastuSLAM LidarScan.
fn to_lidar_scan(points: &[(f32, f32, u8)]) -> LidarScan {
let angles: Vec<f32> = points.iter().map(|(a, _, _)| *a).collect();
let ranges: Vec<f32> = points.iter().map(|(_, d, _)| *d).collect();
LidarScan::new(ranges, angles, 0.15, 8.0)
}
+110
View File
@@ -0,0 +1,110 @@
//! Multi-threaded architecture for DhruvaNav.
//!
//! Separates concerns into three threads:
//! - Sensor thread: High-frequency UDP reading, odometry, safety, commands
//! - Mapping thread: Scan matching and map updates
//! - Exploration thread: Frontier detection and path planning
mod exploration;
mod mapping;
mod sensor;
pub use exploration::ExplorationThread;
pub use mapping::MappingThread;
pub use sensor::SensorThread;
use std::sync::Arc;
use std::sync::mpsc;
use std::thread::{self, JoinHandle};
use crate::config::DhruvaConfig;
use crate::error::Result;
use crate::shared::{
DebugVisualization, SharedDebugViz, SharedGrid, SharedState, SharedTrajectory,
messages::LidarScanMsg,
};
/// Thread handles for the multi-threaded system.
pub struct ThreadHandles {
pub sensor: JoinHandle<()>,
pub mapping: JoinHandle<()>,
pub exploration: JoinHandle<()>,
/// Shared trajectory for SVG visualization (accumulated by mapping thread)
pub trajectory: SharedTrajectory,
/// Shared debug visualization data (accumulated by exploration thread)
pub debug_viz: SharedDebugViz,
}
/// Spawn all threads and return handles.
pub fn spawn_threads(
config: DhruvaConfig,
shared_state: Arc<SharedState>,
shared_grid: SharedGrid,
) -> Result<ThreadHandles> {
// Create channel for lidar scans (bounded to prevent memory growth)
let (lidar_tx, lidar_rx) = mpsc::sync_channel::<LidarScanMsg>(10);
// Create shared trajectory for SVG visualization
let shared_trajectory: SharedTrajectory = Arc::new(std::sync::RwLock::new(Vec::new()));
let mapping_trajectory = Arc::clone(&shared_trajectory);
// Create shared debug visualization data
let shared_debug_viz: SharedDebugViz =
Arc::new(std::sync::RwLock::new(DebugVisualization::default()));
let exploration_debug_viz = Arc::clone(&shared_debug_viz);
// Clone shared state for each thread
let sensor_state = Arc::clone(&shared_state);
let mapping_state = Arc::clone(&shared_state);
let exploration_state = Arc::clone(&shared_state);
// Clone shared grid for exploration
let exploration_grid = Arc::clone(&shared_grid);
// Clone config parts for each thread
let sensor_config = config.clone();
let exploration_config = config.clone();
// Spawn sensor thread (highest priority - real-time constraints)
let sensor_handle = thread::Builder::new()
.name("sensor".into())
.spawn(move || {
let mut sensor_thread = SensorThread::new(sensor_config, sensor_state, lidar_tx);
if let Err(e) = sensor_thread.run() {
tracing::error!("Sensor thread error: {}", e);
}
})
.expect("Failed to spawn sensor thread");
// Spawn mapping thread
let mapping_handle = thread::Builder::new()
.name("mapping".into())
.spawn(move || {
let mut mapping_thread =
MappingThread::new(mapping_state, shared_grid, mapping_trajectory, lidar_rx);
mapping_thread.run();
})
.expect("Failed to spawn mapping thread");
// Spawn exploration thread
let exploration_handle = thread::Builder::new()
.name("exploration".into())
.spawn(move || {
let mut exploration_thread = ExplorationThread::new(
exploration_config,
exploration_state,
exploration_grid,
exploration_debug_viz,
);
exploration_thread.run();
})
.expect("Failed to spawn exploration thread");
Ok(ThreadHandles {
sensor: sensor_handle,
mapping: mapping_handle,
exploration: exploration_handle,
trajectory: shared_trajectory,
debug_viz: shared_debug_viz,
})
}
+240
View File
@@ -0,0 +1,240 @@
//! Sensor thread: High-frequency UDP reading, odometry, safety, and command sending.
//!
//! This thread runs at ~100Hz and handles:
//! - Reading UDP sensor data (non-blocking)
//! - Updating odometry from wheel encoders
//! - Checking safety sensors (bumpers, cliffs)
//! - Sending lidar scans to mapping thread
//! - Sending velocity commands to robot
use std::sync::Arc;
use std::sync::mpsc::SyncSender;
use std::time::{Duration, Instant};
use crate::client::SangamClient;
use crate::config::DhruvaConfig;
use crate::error::Result;
use crate::odometry::Odometry;
use crate::shared::{SharedState, messages::LidarScanMsg};
use vastu_slam::Pose2D;
/// Sensor thread state and logic.
pub struct SensorThread {
config: DhruvaConfig,
shared_state: Arc<SharedState>,
lidar_tx: SyncSender<LidarScanMsg>,
client: Option<SangamClient>,
odometry: Odometry,
/// Last encoder pose - cached for lidar synchronization.
/// Encoder data arrives at ~110Hz, lidar at ~5Hz.
/// When a lidar scan arrives, we use this cached pose (from the most recent
/// encoder update) instead of the current pose to avoid synchronization drift.
last_encoder_pose: Pose2D,
}
impl SensorThread {
/// Create a new sensor thread.
pub fn new(
config: DhruvaConfig,
shared_state: Arc<SharedState>,
lidar_tx: SyncSender<LidarScanMsg>,
) -> Self {
// Robot starts at origin - odometry accumulates from (0,0,0)
let start_pose = Pose2D::new(0.0, 0.0, 0.0);
let odometry = Odometry::new(
config.robot.wheel_base,
config.robot.ticks_per_meter,
start_pose,
);
Self {
config,
shared_state,
lidar_tx,
client: None,
odometry,
last_encoder_pose: start_pose,
}
}
/// Run the sensor thread main loop.
pub fn run(&mut self) -> Result<()> {
// Connect to SangamIO
self.connect()?;
let command_interval = Duration::from_millis(50); // 20Hz command rate
let mut last_command_time = Instant::now();
tracing::info!("Sensor thread started");
// Main sensor loop
loop {
// Check for shutdown
if self.shared_state.should_shutdown() {
tracing::info!("Sensor thread shutting down");
self.send_stop();
break;
}
// Check for safety stop
if self.shared_state.is_safety_stop() {
self.send_stop();
// Continue running to maintain connection but don't process
std::thread::sleep(Duration::from_millis(100));
continue;
}
// Drain UDP buffer
self.process_udp_messages();
// Send velocity command at fixed interval
if last_command_time.elapsed() >= command_interval {
self.send_velocity_command();
last_command_time = Instant::now();
}
// Small sleep to avoid busy-waiting (target ~100Hz)
std::thread::sleep(Duration::from_millis(5));
}
Ok(())
}
/// Connect to SangamIO daemon.
fn connect(&mut self) -> Result<()> {
let addr = self.config.address();
tracing::info!("Sensor thread connecting to SangamIO at {}...", addr);
let timeout = Duration::from_millis(self.config.connection.timeout_ms);
let mut client = SangamClient::connect_timeout(&addr, timeout)?;
// Set non-blocking for UDP reads
client.set_timeout(Some(Duration::from_millis(10)))?;
// Enable drive and lidar components
tracing::info!("Enabling drive and lidar...");
client.enable_drive()?;
client.enable_lidar()?;
// Wait for sensor data to start flowing
std::thread::sleep(Duration::from_millis(500));
self.client = Some(client);
tracing::info!("Sensor thread connected to SangamIO");
Ok(())
}
/// Process all available UDP messages.
fn process_udp_messages(&mut self) {
let client = match self.client.as_mut() {
Some(c) => c,
None => return,
};
let mut messages_processed = 0;
const MAX_MESSAGES_PER_ITERATION: usize = 50;
// Buffer for the latest lidar scan
let mut pending_lidar: Option<LidarScanMsg> = None;
loop {
match client.recv_udp() {
Ok(Some(msg)) => {
messages_processed += 1;
// Update odometry from encoders
if let Some((left, right)) = msg.encoder_ticks() {
self.odometry.update(left, right);
// Cache pose for lidar synchronization
self.last_encoder_pose = self.odometry.pose();
// Update shared odometry (mapping thread will apply corrections)
self.shared_state.set_odometry(self.last_encoder_pose);
}
// Check safety sensors - bumpers
if let Some((bump_l, bump_r)) = msg.bumper_state()
&& (bump_l || bump_r)
{
self.shared_state.trigger_safety_stop(format!(
"Bumper triggered: left={}, right={}",
bump_l, bump_r
));
return;
}
// Check safety sensors - cliffs
if let Some((cl_ls, cl_lf, cl_rf, cl_rs)) = msg.cliff_state()
&& (cl_ls || cl_lf || cl_rf || cl_rs)
{
self.shared_state.trigger_safety_stop(format!(
"Cliff detected: ls={}, lf={}, rf={}, rs={}",
cl_ls, cl_lf, cl_rf, cl_rs
));
return;
}
// Buffer lidar scan (keep only the latest)
// Use cached encoder pose for synchronization - encoder data arrives
// at ~110Hz, lidar at ~5Hz, so last_encoder_pose is at most ~9ms old
if let Some(points) = msg.as_lidar() {
pending_lidar = Some(LidarScanMsg {
points: points.clone(),
encoder_pose: self.last_encoder_pose,
});
}
// Limit messages per iteration
if messages_processed >= MAX_MESSAGES_PER_ITERATION {
break;
}
}
Ok(None) => {
// No more data available
break;
}
Err(e) => {
tracing::error!("UDP receive error: {}", e);
self.shared_state
.trigger_safety_stop(format!("Connection error: {}", e));
return;
}
}
}
// Send the latest lidar scan to mapping thread
if let Some(scan_msg) = pending_lidar {
// Use try_send to avoid blocking if channel is full
if let Err(e) = self.lidar_tx.try_send(scan_msg) {
tracing::warn!("Failed to send lidar scan to mapping thread: {}", e);
}
}
}
/// Send velocity command to robot.
fn send_velocity_command(&mut self) {
let client = match self.client.as_mut() {
Some(c) => c,
None => return,
};
let (linear, angular) = self.shared_state.velocity();
if let Err(e) = client.send_drive_command(linear, angular) {
tracing::error!("Failed to send drive command: {}", e);
self.shared_state
.trigger_safety_stop(format!("Command send failed: {}", e));
}
}
/// Send stop command.
fn send_stop(&mut self) {
if let Some(client) = self.client.as_mut()
&& let Err(e) = client.send_stop()
{
tracing::error!("Failed to send stop command: {}", e);
}
}
}
+16
View File
@@ -0,0 +1,16 @@
//! Shared utility functions
use std::f32::consts::PI;
/// Normalize angle to [-π, π]
#[inline]
pub fn normalize_angle(angle: f32) -> f32 {
let mut a = angle;
while a > PI {
a -= 2.0 * PI;
}
while a < -PI {
a += 2.0 * PI;
}
a
}