Split large vastu-map modules into subdirectories
Break down monolithic files into focused submodules: - config.rs → config/ with separate files per concern - exploration/ → explore/controller/ with state machine - grid/storage.rs → grid/storage/ module - pathfinding/astar.rs → pathfinding/astar/ module - query/traversability.rs → query/traversability/ module - slam/matcher.rs → slam/matcher/ with helpers Add new SLAM components: - branch_bound/ for scan matching optimization - background_optimizer.rs for async graph optimization Add new explore components: - error.rs, motion_filter.rs, velocity.rs
This commit is contained in:
@@ -380,7 +380,6 @@ impl GraphOptimizer {
|
||||
}
|
||||
|
||||
/// Add edge contribution to H and b.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn add_edge_contribution(
|
||||
&self,
|
||||
h: &mut [f64],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# VastuMap Occupancy Grid Configuration
|
||||
# Inspired by Google Cartographer SLAM parameters
|
||||
# Units: meters (m), radians (rad), cells (n)
|
||||
|
||||
# ============================================================================
|
||||
@@ -6,7 +7,7 @@
|
||||
# ============================================================================
|
||||
grid:
|
||||
# Cell resolution in meters per cell
|
||||
resolution: 0.025 # 2.5cm cells
|
||||
resolution: 0.025 # 2.5cm cells (Cartographer default: 0.05)
|
||||
|
||||
# Initial grid dimensions (cells)
|
||||
initial_width: 300
|
||||
@@ -22,6 +23,25 @@ grid:
|
||||
# Enable automatic grid expansion
|
||||
auto_expand: true
|
||||
|
||||
# Log-odds occupancy model (Cartographer-style)
|
||||
# Lower values = more observations needed to establish walls
|
||||
log_odds:
|
||||
# Log-odds increment for hit (lidar endpoint)
|
||||
# Cartographer: ~20 (P=0.55), VastuMap aggressive: 70 (P=0.67)
|
||||
l_hit: 20 # Cartographer default: ~3-5 observations for wall
|
||||
|
||||
# Log-odds decrement for miss (ray passes through)
|
||||
# Cartographer: ~-4 (P=0.49), VastuMap aggressive: -28 (P=0.43)
|
||||
l_miss: -4 # Cartographer default: asymmetric for sticky walls
|
||||
|
||||
# Cell classification thresholds
|
||||
l_occupied_threshold: 50
|
||||
l_free_threshold: -50
|
||||
|
||||
# Clamping range (probability bounds)
|
||||
l_min: -200 # P ≈ 0.12
|
||||
l_max: 200 # P ≈ 0.88
|
||||
|
||||
# ============================================================================
|
||||
# Sensor Configuration
|
||||
# ============================================================================
|
||||
@@ -52,6 +72,142 @@ sensor:
|
||||
left_angle: 0.5 # ~30 degrees
|
||||
right_angle: -0.5
|
||||
|
||||
# ============================================================================
|
||||
# SLAM Configuration
|
||||
# ============================================================================
|
||||
slam:
|
||||
# Correlative scan matcher (local SLAM)
|
||||
correlative:
|
||||
enabled: true
|
||||
|
||||
# Search window
|
||||
search_x: 0.3 # meters
|
||||
search_y: 0.3 # meters
|
||||
search_theta: 0.15 # radians (~8.6°)
|
||||
|
||||
# Search resolution (finer = more accurate but slower)
|
||||
linear_resolution: 0.01 # Was 0.02, now 1cm
|
||||
angular_resolution: 0.01 # Was 0.02, now ~0.57°
|
||||
|
||||
# Multi-resolution search (coarse-to-fine)
|
||||
multi_resolution: true
|
||||
coarse_factor: 2.0
|
||||
|
||||
# Gaussian scoring (uses distance field)
|
||||
use_gaussian_scoring: true
|
||||
gaussian_sigma: 0.05 # Was 0.10, now 2x grid resolution
|
||||
|
||||
# Gauss-Newton refinement for sub-pixel accuracy
|
||||
gn_max_iterations: 10
|
||||
gn_convergence_threshold: 1e-5
|
||||
gn_damping: 1e-3
|
||||
gn_translation_weight: 500.0 # Trust encoder distance
|
||||
gn_rotation_weight: 20.0 # Allow heading correction
|
||||
|
||||
# Match quality threshold
|
||||
min_score: 0.5
|
||||
|
||||
# Maximum points to use (0 = all)
|
||||
max_points: 0
|
||||
|
||||
# Optional: skip correlative search for reliable odometry
|
||||
skip_when_confident: false
|
||||
skip_confidence_threshold: 0.8
|
||||
|
||||
# Branch-and-bound matcher (for loop closure)
|
||||
# More efficient than brute-force for large search windows
|
||||
branch_bound:
|
||||
enabled: true
|
||||
|
||||
# Large search window for loop closure
|
||||
search_x: 2.0 # meters (Cartographer global: ~7m)
|
||||
search_y: 2.0 # meters
|
||||
search_theta: 0.5 # radians (~28°)
|
||||
|
||||
# Angular resolution
|
||||
angular_resolution: 0.02 # ~1.1°
|
||||
|
||||
# Minimum score ratio (fraction of max possible)
|
||||
min_score_ratio: 0.5
|
||||
|
||||
# Loop closure detection
|
||||
loop_closure:
|
||||
enabled: true
|
||||
|
||||
# Keyframe creation thresholds
|
||||
keyframe_distance: 0.3 # Was 0.5, now denser
|
||||
keyframe_rotation: 0.35 # Was 0.5 (~20° now)
|
||||
|
||||
# LiDAR-IRIS descriptor matching
|
||||
max_descriptor_distance: 80 # Was 100, now stricter (out of 640)
|
||||
|
||||
# Spatial constraints
|
||||
min_loop_gap: 20 # Minimum pose index gap
|
||||
max_loop_distance: 10.0 # Maximum spatial distance (m)
|
||||
|
||||
# Verification
|
||||
min_verification_score: 0.6
|
||||
use_branch_bound: true # Use B&B instead of correlative
|
||||
|
||||
# Verification search window (fallback if B&B disabled)
|
||||
verification_search_window: 0.2
|
||||
verification_angular_window: 0.2
|
||||
|
||||
# ============================================================================
|
||||
# Submap Configuration
|
||||
# ============================================================================
|
||||
submap:
|
||||
scans_per_submap: 50 # Cartographer: 90
|
||||
overlap_scans: 10 # Smooth transitions between submaps
|
||||
local_grid_size: 200 # 5m × 5m at 2.5cm resolution
|
||||
max_distance_per_submap: 3.0 # Force new submap after this distance
|
||||
max_submaps: 100 # Maximum submaps to store
|
||||
|
||||
# Loop closure between submaps
|
||||
min_loop_closure_gap: 3 # Minimum submap index gap
|
||||
max_loop_closure_distance: 10.0
|
||||
|
||||
# ============================================================================
|
||||
# Pose Graph Optimization
|
||||
# ============================================================================
|
||||
pose_graph:
|
||||
# Optimization trigger
|
||||
optimize_every_n_nodes: 0 # 0 = only on loop closure
|
||||
|
||||
# Solver parameters
|
||||
max_iterations: 50
|
||||
convergence_threshold: 1e-5
|
||||
|
||||
# Huber loss for robust optimization (downweight outliers)
|
||||
huber_scale: 0.1 # 10cm
|
||||
|
||||
# Edge weights
|
||||
odometry_weight: 100.0 # Strong trust in encoder odometry
|
||||
loop_closure_weight: 50.0 # Lower to handle noise
|
||||
|
||||
# Background thread (optional, not recommended for ARM)
|
||||
use_background_thread: false
|
||||
|
||||
# ============================================================================
|
||||
# Motion Filter Configuration (Cartographer-style)
|
||||
# ============================================================================
|
||||
# Gates scan processing based on robot movement to reduce redundant observations
|
||||
# and save CPU when stationary. A scan is processed if ANY threshold is exceeded.
|
||||
motion_filter:
|
||||
# Enable/disable motion filtering (true = filter enabled)
|
||||
enabled: true
|
||||
|
||||
# Maximum time between scan insertions (seconds)
|
||||
# Ensures at least some updates even when stationary
|
||||
max_time_secs: 5.0
|
||||
|
||||
# Maximum distance before forcing scan insertion (meters)
|
||||
max_distance: 0.2
|
||||
|
||||
# Maximum rotation before forcing scan insertion (radians)
|
||||
# 0.17 rad ≈ 10 degrees
|
||||
max_angle: 0.17
|
||||
|
||||
# ============================================================================
|
||||
# Query Configuration
|
||||
# ============================================================================
|
||||
|
||||
@@ -1,594 +0,0 @@
|
||||
# Vastu-Map Accuracy Improvement Proposal
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines remaining architectural and algorithmic gaps between Vastu-Map and Google Cartographer, with proposed solutions to achieve comparable accuracy for 2D SLAM on embedded platforms.
|
||||
|
||||
**Goal**: Improve scan-to-map matching accuracy while maintaining exploration-friendly occupancy grid representation.
|
||||
|
||||
**Constraints**:
|
||||
- ARM Cortex-A7 target (Allwinner A33)
|
||||
- No external optimizer dependencies (Ceres, g2o)
|
||||
- Real-time operation at 5Hz lidar rate
|
||||
- Memory budget: <50MB for map storage
|
||||
|
||||
---
|
||||
|
||||
## Completed Items
|
||||
|
||||
The following features from the original proposal have been fully implemented:
|
||||
|
||||
### Submap Architecture (Gap 1 - COMPLETED)
|
||||
|
||||
Full submap architecture with state machine lifecycle:
|
||||
- `Submap` struct with origin, local grid, raw scan storage
|
||||
- `SubmapManager` with Active → Filling → Finalized state machine
|
||||
- Overlap scan handling during submap transitions
|
||||
- Lazy global grid recomposition with dirty flag caching
|
||||
- Raw scan storage for pose graph adjustment
|
||||
|
||||
**Location**: `src/submap/` (types.rs, manager.rs)
|
||||
|
||||
### Loop Closure Detection (COMPLETED)
|
||||
|
||||
LiDAR-IRIS descriptor-based loop closure detection:
|
||||
- Descriptor computation and matching
|
||||
- Pose graph structure with odometry and loop edges
|
||||
|
||||
**Location**: `src/slam/loop_closure/`
|
||||
|
||||
### SIMD Optimizations (PARTIAL - Foundation Complete)
|
||||
|
||||
Basic SIMD infrastructure using `std::simd`:
|
||||
- `f32x4` batch point transformations
|
||||
- SoA grid storage with `u8x16` vectorization
|
||||
- ARM NEON / x86 SSE portable implementation
|
||||
|
||||
**Location**: `src/core/simd.rs`, `src/grid/storage.rs`
|
||||
|
||||
### Gauss-Newton Refinement (PARTIAL)
|
||||
|
||||
Scan matching with Cartographer-style prior constraints:
|
||||
- Prior weights for translation (500.0) and rotation (20.0)
|
||||
- Fixed LM damping (λ=1e-3)
|
||||
|
||||
**Location**: `src/slam/matcher.rs`
|
||||
|
||||
---
|
||||
|
||||
## Remaining Gaps (Priority Order)
|
||||
|
||||
### Priority 1: Motion Filter
|
||||
|
||||
**Current State**:
|
||||
Every lidar scan triggers scan matching and grid update.
|
||||
|
||||
**Problem**:
|
||||
- Stationary robot accumulates redundant observations
|
||||
- Wasted computation on identical poses
|
||||
- Submap size varies with robot speed
|
||||
|
||||
**Cartographer's Approach**:
|
||||
Motion filter gates scan insertion:
|
||||
- `max_time_seconds`: 5s (insert at least every 5s)
|
||||
- `max_distance_meters`: 0.2m
|
||||
- `max_angle_radians`: 0.17 rad (~10°)
|
||||
|
||||
**Proposed Solution**:
|
||||
|
||||
```rust
|
||||
pub struct MotionFilter {
|
||||
last_pose: Pose2D,
|
||||
last_time: Instant,
|
||||
config: MotionFilterConfig,
|
||||
}
|
||||
|
||||
pub struct MotionFilterConfig {
|
||||
pub max_time: Duration, // 5s
|
||||
pub max_distance: f32, // 0.2m
|
||||
pub max_angle: f32, // 0.17 rad
|
||||
}
|
||||
|
||||
impl MotionFilter {
|
||||
pub fn should_process(&mut self, pose: Pose2D, time: Instant) -> bool {
|
||||
let dt = time.duration_since(self.last_time);
|
||||
let dist = (pose.x - self.last_pose.x).hypot(pose.y - self.last_pose.y);
|
||||
let angle = (pose.theta - self.last_pose.theta).abs();
|
||||
|
||||
let should = dt > self.config.max_time
|
||||
|| dist > self.config.max_distance
|
||||
|| angle > self.config.max_angle;
|
||||
|
||||
if should {
|
||||
self.last_pose = pose;
|
||||
self.last_time = time;
|
||||
}
|
||||
|
||||
should
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation Effort**: Low (1-2 hours)
|
||||
**Impact**: Reduces CPU usage, consistent submap sizes
|
||||
|
||||
---
|
||||
|
||||
### Priority 2: Log-Odds Probability Cells
|
||||
|
||||
**Current State**:
|
||||
Cells use a `confidence: u8` counter with priority-based type overwriting:
|
||||
```rust
|
||||
pub struct Cell {
|
||||
pub cell_type: CellType, // Unknown/Floor/Wall/Cliff/Bump
|
||||
pub confidence: u8, // 0-255, saturates
|
||||
pub observation_count: u8,
|
||||
pub swept: bool,
|
||||
}
|
||||
```
|
||||
|
||||
**Problem**:
|
||||
- Counter saturates quickly, losing uncertainty information
|
||||
- No principled way to combine conflicting observations
|
||||
- Priority system prevents dynamic obstacle handling
|
||||
|
||||
**Cartographer's Approach**:
|
||||
Proper Bayesian update using log-odds representation:
|
||||
```
|
||||
P(occupied | observations) ∝ P(observations | occupied) × P(occupied)
|
||||
```
|
||||
|
||||
In log-odds space, multiplication becomes addition:
|
||||
```
|
||||
L(x) = log(P(x) / (1 - P(x)))
|
||||
L_new = L_old + L_observation
|
||||
```
|
||||
|
||||
**Proposed Solution**:
|
||||
|
||||
```rust
|
||||
pub struct Cell {
|
||||
/// Log-odds occupancy (fixed-point: actual = value / 100)
|
||||
/// Range: [-200, 200] maps to probability [0.12, 0.88]
|
||||
pub log_odds: i16,
|
||||
|
||||
/// Semantic type (Wall, Floor, Cliff, Bump)
|
||||
pub cell_type: CellType,
|
||||
|
||||
/// Last observation timestamp (for decay)
|
||||
pub last_seen: u16,
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
// Observation log-odds (tunable parameters)
|
||||
const L_HIT: i16 = 70; // P=0.67 → confident occupied
|
||||
const L_MISS: i16 = -30; // P=0.43 → slight evidence of free
|
||||
const L_MIN: i16 = -200; // P=0.12 → very likely free
|
||||
const L_MAX: i16 = 200; // P=0.88 → very likely occupied
|
||||
|
||||
pub fn apply_hit(&mut self) {
|
||||
self.log_odds = (self.log_odds.saturating_add(Self::L_HIT))
|
||||
.clamp(Self::L_MIN, Self::L_MAX);
|
||||
self.cell_type = CellType::Wall;
|
||||
}
|
||||
|
||||
pub fn apply_miss(&mut self) {
|
||||
self.log_odds = (self.log_odds.saturating_add(Self::L_MISS))
|
||||
.clamp(Self::L_MIN, Self::L_MAX);
|
||||
if self.log_odds < 0 {
|
||||
self.cell_type = CellType::Floor;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn probability(&self) -> f32 {
|
||||
let l = self.log_odds as f32 / 100.0;
|
||||
1.0 / (1.0 + (-l).exp())
|
||||
}
|
||||
|
||||
pub fn is_occupied(&self) -> bool {
|
||||
self.log_odds > 50 // P > 0.62
|
||||
}
|
||||
|
||||
pub fn is_free(&self) -> bool {
|
||||
self.log_odds < -50 // P < 0.38
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Migration Path**:
|
||||
1. Add `log_odds: i16` to GridStorage (SoA layout already supports this)
|
||||
2. Update `apply_observation()` to use log-odds arithmetic
|
||||
3. Keep `cell_type` for semantic information (Cliff, Bump sensors)
|
||||
4. Update scan matching scoring to use probability values
|
||||
|
||||
**Implementation Effort**: Medium (4-6 hours)
|
||||
**Impact**: Better uncertainty handling, dynamic obstacle support, thinner walls
|
||||
|
||||
---
|
||||
|
||||
### Priority 3: Adaptive Levenberg-Marquardt
|
||||
|
||||
**Current State**:
|
||||
Gauss-Newton with fixed LM damping (λ=1e-3) in both scan matching and pose graph:
|
||||
```rust
|
||||
// src/slam/matcher.rs line 273
|
||||
let damping = self.config.gn_damping; // Fixed 1e-3
|
||||
```
|
||||
|
||||
**Problem**:
|
||||
- Fixed damping may be too aggressive (slow convergence) or too weak (overshoot)
|
||||
- No step acceptance/rejection logic
|
||||
- Convergence not guaranteed
|
||||
|
||||
**Cartographer's Approach**:
|
||||
Ceres Solver with trust-region method (automatic damping adjustment).
|
||||
|
||||
**Proposed Solution** (without Ceres dependency):
|
||||
|
||||
```rust
|
||||
pub struct AdaptiveLM {
|
||||
lambda: f64,
|
||||
lambda_factor: f64,
|
||||
min_lambda: f64,
|
||||
max_lambda: f64,
|
||||
}
|
||||
|
||||
impl AdaptiveLM {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
lambda: 1e-3,
|
||||
lambda_factor: 10.0,
|
||||
min_lambda: 1e-7,
|
||||
max_lambda: 1e7,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn step(
|
||||
&mut self,
|
||||
current_cost: f64,
|
||||
jacobian: &Matrix3xN,
|
||||
residuals: &VectorN,
|
||||
) -> (Vector3, StepResult) {
|
||||
let jtj = jacobian.transpose() * jacobian;
|
||||
let jtr = jacobian.transpose() * residuals;
|
||||
|
||||
loop {
|
||||
// Add damping to diagonal
|
||||
let mut damped = jtj.clone();
|
||||
damped[(0,0)] += self.lambda;
|
||||
damped[(1,1)] += self.lambda;
|
||||
damped[(2,2)] += self.lambda;
|
||||
|
||||
// Solve for step
|
||||
let delta = solve_3x3(&damped, &(-jtr));
|
||||
|
||||
// Predict cost reduction using linear model
|
||||
let predicted_reduction = -delta.dot(&jtr)
|
||||
- 0.5 * delta.dot(&(jtj * &delta));
|
||||
|
||||
if predicted_reduction > 0.0 {
|
||||
// Step looks promising
|
||||
return (delta, StepResult::TryStep {
|
||||
predicted_reduction,
|
||||
lambda_on_success: (self.lambda / self.lambda_factor)
|
||||
.max(self.min_lambda),
|
||||
lambda_on_failure: (self.lambda * self.lambda_factor)
|
||||
.min(self.max_lambda),
|
||||
});
|
||||
}
|
||||
|
||||
// Predicted to increase cost, increase damping
|
||||
self.lambda = (self.lambda * self.lambda_factor).min(self.max_lambda);
|
||||
|
||||
if self.lambda >= self.max_lambda {
|
||||
return (Vector3::zeros(), StepResult::Converged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn accept_step(&mut self, actual_reduction: f64, predicted_reduction: f64) {
|
||||
let rho = actual_reduction / predicted_reduction;
|
||||
|
||||
if rho > 0.25 {
|
||||
// Good step, decrease damping
|
||||
self.lambda = (self.lambda / self.lambda_factor).max(self.min_lambda);
|
||||
} else if rho < 0.1 {
|
||||
// Bad step, increase damping
|
||||
self.lambda = (self.lambda * self.lambda_factor).min(self.max_lambda);
|
||||
}
|
||||
// Otherwise keep lambda unchanged
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation Effort**: Medium (3-4 hours)
|
||||
**Impact**: Better convergence, fewer iterations, more robust matching
|
||||
|
||||
---
|
||||
|
||||
### Priority 4: Branch-and-Bound Search
|
||||
|
||||
**Current State**:
|
||||
Correlative scan matching uses 2-level coarse-to-fine search:
|
||||
- Coarse: resolution × 2.0 factor over 30cm window
|
||||
- Fine: Full resolution around best coarse candidate
|
||||
|
||||
Evaluates ~2,000-5,000 poses per match.
|
||||
|
||||
**Problem**:
|
||||
- Cannot efficiently handle larger search windows (>30cm)
|
||||
- Poor odometry requires 50cm+ windows
|
||||
- Computational cost scales linearly with window size
|
||||
|
||||
**Cartographer's Approach**:
|
||||
Branch-and-bound with precomputed multi-resolution grids:
|
||||
- 4 resolution levels (1×, 4×, 16×, 64× cell size)
|
||||
- Each coarse cell stores max probability of all finer cells
|
||||
- DFS search with pruning: if upper_bound ≤ best_score, skip subtree
|
||||
|
||||
**Proposed Solution**:
|
||||
|
||||
```
|
||||
Precomputed Grid Hierarchy:
|
||||
|
||||
Level 0 (2.5cm): [0.8][0.2][0.9][0.1][0.7][0.3][0.8][0.2]
|
||||
\ / \ / \ / \ /
|
||||
Level 1 (10cm): [0.8] [0.9] [0.7] [0.8]
|
||||
\ / \ /
|
||||
Level 2 (40cm): [0.9] [0.8]
|
||||
\ /
|
||||
Level 3 (160cm): [0.9]
|
||||
|
||||
Upper bound: max of children guarantees no better solution exists below
|
||||
```
|
||||
|
||||
```rust
|
||||
pub struct PrecomputedGrids {
|
||||
/// Resolution levels: [2.5cm, 10cm, 40cm, 160cm]
|
||||
levels: [Grid; 4],
|
||||
}
|
||||
|
||||
impl PrecomputedGrids {
|
||||
pub fn from_submap(grid: &LocalGrid) -> Self {
|
||||
let mut levels = [Grid::default(); 4];
|
||||
levels[0] = grid.probability_grid();
|
||||
|
||||
for i in 1..4 {
|
||||
levels[i] = Self::downsample_max(&levels[i-1], 4);
|
||||
}
|
||||
|
||||
Self { levels }
|
||||
}
|
||||
|
||||
fn downsample_max(grid: &Grid, factor: usize) -> Grid {
|
||||
// Each output cell = max of factor×factor input cells
|
||||
let mut out = Grid::new(grid.width / factor, grid.height / factor);
|
||||
for y in 0..out.height {
|
||||
for x in 0..out.width {
|
||||
let mut max_val = 0.0f32;
|
||||
for dy in 0..factor {
|
||||
for dx in 0..factor {
|
||||
max_val = max_val.max(
|
||||
grid.get(x * factor + dx, y * factor + dy)
|
||||
);
|
||||
}
|
||||
}
|
||||
out.set(x, y, max_val);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
pub fn branch_and_bound_match(
|
||||
scan: &[Point2D],
|
||||
grids: &PrecomputedGrids,
|
||||
search_window: SearchWindow,
|
||||
) -> Option<(Pose2D, f32)> {
|
||||
let mut best = (Pose2D::identity(), 0.0f32);
|
||||
let mut stack = Vec::with_capacity(1024);
|
||||
|
||||
// Start with coarsest level covering entire window
|
||||
stack.push(SearchNode {
|
||||
level: 3,
|
||||
x_range: search_window.x_range,
|
||||
y_range: search_window.y_range,
|
||||
theta_range: search_window.theta_range,
|
||||
});
|
||||
|
||||
while let Some(node) = stack.pop() {
|
||||
// Compute upper bound at this resolution
|
||||
let upper_bound = score_scan_upper_bound(
|
||||
scan,
|
||||
&grids.levels[node.level],
|
||||
node.center_pose(),
|
||||
);
|
||||
|
||||
// Prune if cannot beat current best
|
||||
if upper_bound <= best.1 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if node.level == 0 {
|
||||
// Finest level: compute exact score
|
||||
let score = score_scan_exact(scan, &grids.levels[0], node.center_pose());
|
||||
if score > best.1 {
|
||||
best = (node.center_pose(), score);
|
||||
}
|
||||
} else {
|
||||
// Subdivide into 8 children (2×2×2 in x,y,theta)
|
||||
for child in node.subdivide() {
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(best.1 > MIN_MATCH_SCORE).then_some(best)
|
||||
}
|
||||
```
|
||||
|
||||
**Complexity**:
|
||||
- Brute force: O(W² × A) where W=window size, A=angular steps
|
||||
- Branch-and-bound: O(log(W) × k) where k=matched poses (typically <100)
|
||||
|
||||
**Implementation Effort**: High (8-12 hours)
|
||||
**Impact**: 10× speedup for large search windows, enables 50cm+ windows
|
||||
|
||||
---
|
||||
|
||||
### Priority 5: Background Pose Graph Optimization
|
||||
|
||||
**Current State**:
|
||||
Pose graph optimization runs synchronously when loop closure is detected.
|
||||
|
||||
**Problem**:
|
||||
- Blocks scan processing during optimization (100-500ms)
|
||||
- Cannot continuously refine poses as new constraints arrive
|
||||
- Single-shot optimization may not converge fully
|
||||
|
||||
**Cartographer's Approach**:
|
||||
- Background thread runs optimization continuously
|
||||
- Triggered every N nodes (configurable)
|
||||
- Results applied incrementally to trajectory
|
||||
- Multiple iterations for better convergence
|
||||
|
||||
**Proposed Solution**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ SLAM Thread │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
|
||||
│ │ Scan │───▶│ Match │───▶│ Insert to Submap │ │
|
||||
│ │ Receive │ │ (Local) │ │ + Add Graph Node │ │
|
||||
│ └──────────┘ └──────────┘ └────────┬─────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────┐ │
|
||||
│ │ Loop Closure Detector │ │
|
||||
│ │ (descriptor matching) │ │
|
||||
│ └───────────┬────────────┘ │
|
||||
│ │ │
|
||||
└──────────────────────────────────────────┼──────────────┘
|
||||
│ trigger
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Background Optimizer Thread │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Copy Graph │───▶│ Optimize │───▶│ Publish │ │
|
||||
│ │ Snapshot │ │ (50 iters) │ │ Corrections │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
```rust
|
||||
pub struct BackgroundOptimizer {
|
||||
graph: Arc<RwLock<PoseGraph>>,
|
||||
config: OptimizationConfig,
|
||||
}
|
||||
|
||||
pub struct OptimizationConfig {
|
||||
pub trigger_every_n_nodes: usize, // 20
|
||||
pub max_iterations: usize, // 50
|
||||
pub convergence_threshold: f64, // 1e-5
|
||||
pub huber_scale: f64, // 0.1 (10cm)
|
||||
pub odometry_weight: f64, // 100.0
|
||||
pub loop_closure_weight: f64, // 50.0
|
||||
}
|
||||
|
||||
impl BackgroundOptimizer {
|
||||
pub fn run(&self, trigger_rx: Receiver<()>, result_tx: Sender<PoseCorrections>) {
|
||||
while trigger_rx.recv().is_ok() {
|
||||
// Snapshot current graph
|
||||
let graph_snapshot = self.graph.read().clone();
|
||||
|
||||
// Run optimization (doesn't block SLAM thread)
|
||||
let optimized = self.optimize(&graph_snapshot);
|
||||
|
||||
// Compute corrections
|
||||
let corrections = self.compute_corrections(&graph_snapshot, &optimized);
|
||||
|
||||
// Send back to SLAM thread
|
||||
let _ = result_tx.send(corrections);
|
||||
}
|
||||
}
|
||||
|
||||
fn optimize(&self, graph: &PoseGraph) -> Vec<Pose2D> {
|
||||
let mut poses = graph.poses.clone();
|
||||
|
||||
for iteration in 0..self.config.max_iterations {
|
||||
let (h, b) = self.build_linear_system(graph, &poses);
|
||||
let delta = self.solve_sparse(&h, &b);
|
||||
|
||||
// Apply update
|
||||
let max_update = self.apply_update(&mut poses, &delta);
|
||||
|
||||
if max_update < self.config.convergence_threshold {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
poses
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation Effort**: Medium-High (6-8 hours)
|
||||
**Impact**: Non-blocking optimization, better convergence
|
||||
|
||||
**Note**: For embedded ARM target, background threading may not be critical. Synchronous optimization at 100-500ms is acceptable if triggered only on loop closure events (rare).
|
||||
|
||||
---
|
||||
|
||||
## Updated Implementation Roadmap
|
||||
|
||||
```
|
||||
Phase 1: Quick Wins (Immediate)
|
||||
├── Motion filter ← PRIORITY 1 (1-2 hours)
|
||||
└── Adaptive LM refinement ← PRIORITY 3 (3-4 hours)
|
||||
|
||||
Phase 2: Accuracy Improvement
|
||||
└── Log-odds probability cells ← PRIORITY 2 (4-6 hours)
|
||||
|
||||
Phase 3: Performance
|
||||
├── Branch-and-bound matcher ← PRIORITY 4 (8-12 hours)
|
||||
├── Precomputed grid hierarchy
|
||||
└── SIMD for B&B scoring
|
||||
|
||||
Phase 4: Optional (if needed)
|
||||
└── Background optimizer thread ← PRIORITY 5 (6-8 hours)
|
||||
(may not be needed for embedded target)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Memory Budget Analysis (Updated)
|
||||
|
||||
| Component | Current | After Phase 2 | Notes |
|
||||
|-----------|---------|---------------|-------|
|
||||
| Global Grid (800×800) | 2.5 MB | 4 MB | +log_odds (i16) |
|
||||
| Submaps (10 active) | 2 MB | 2 MB | Already implemented |
|
||||
| Raw Scans (1000) | 2 MB | 2 MB | Already implemented |
|
||||
| Precomputed Grids | - | 3 MB | Phase 3 |
|
||||
| Pose Graph | 0.5 MB | 0.5 MB | Already implemented |
|
||||
| **Total** | ~7 MB | ~12 MB | Within 50MB budget |
|
||||
|
||||
---
|
||||
|
||||
## Expected Accuracy Improvements
|
||||
|
||||
| Metric | Current | After Phase 1 | After Phase 2 | Method |
|
||||
|--------|---------|---------------|---------------|--------|
|
||||
| Drift (per 10m) | ~4cm | ~3cm | ~2cm | Adaptive LM |
|
||||
| Loop closure error | ~2cm | ~2cm | ~1cm | Already good |
|
||||
| Wall thickness | 5-8cm | 5-8cm | 2-3cm | Log-odds |
|
||||
| CPU per match | 15ms | 12ms | 12ms | Motion filter |
|
||||
| Ghost artifacts | Rare | Rare | Very rare | Already good |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
1. Hess, W., et al. "Real-Time Loop Closure in 2D LIDAR SLAM." IEEE ICRA 2016.
|
||||
2. Cartographer ROS Documentation: https://google-cartographer-ros.readthedocs.io/
|
||||
3. Olson, E. "Real-Time Correlative Scan Matching." IEEE ICRA 2009.
|
||||
4. Konolige, K. "Improved Occupancy Grids for Map Building." Autonomous Robots 1997.
|
||||
@@ -295,8 +295,7 @@ struct DriftStats {
|
||||
enum ExplorationStatus {
|
||||
Complete,
|
||||
MaxStepsReached,
|
||||
#[allow(dead_code)]
|
||||
Failed(String),
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Simple wheel odometry tracker
|
||||
@@ -603,7 +602,7 @@ fn run_exploration(
|
||||
println!("\nExploration failed: {}", reason);
|
||||
return ExplorationResult {
|
||||
map,
|
||||
status: ExplorationStatus::Failed(reason),
|
||||
status: ExplorationStatus::Failed,
|
||||
steps: step,
|
||||
elapsed_secs: start_time.elapsed().as_secs_f32(),
|
||||
progress: explorer.progress(),
|
||||
|
||||
@@ -237,8 +237,8 @@ fn main() {
|
||||
println!("Wall cells: {}", stats.wall_cells);
|
||||
println!("Coverage: {:.2}m²", stats.explored_area_m2);
|
||||
}
|
||||
ExploreResult::Failed { map, reason } => {
|
||||
println!("\nExploration failed: {}", reason);
|
||||
ExploreResult::Failed { map, error } => {
|
||||
println!("\nExploration failed: {}", error);
|
||||
|
||||
// Save partial map
|
||||
let output_base = Path::new(&args.output).join(format!("{}_partial", map_name));
|
||||
|
||||
@@ -1,672 +0,0 @@
|
||||
//! Unified configuration loading for VastuMap.
|
||||
//!
|
||||
//! Loads all configuration from a single YAML file.
|
||||
|
||||
use crate::core::WorldPoint;
|
||||
use crate::exploration::ExplorationConfig;
|
||||
use crate::explore::ExplorerConfig;
|
||||
use crate::grid::{GridConfig, MapConfig, SensorConfig};
|
||||
use crate::pathfinding::{AStarConfig, SmoothingConfig};
|
||||
use crate::query::{FrontierDetector, RobotFootprint};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
/// Full VastuMap configuration loaded from YAML
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
pub struct VastuConfig {
|
||||
/// Grid settings
|
||||
#[serde(default)]
|
||||
pub grid: GridSection,
|
||||
|
||||
/// Sensor settings
|
||||
#[serde(default)]
|
||||
pub sensor: SensorSection,
|
||||
|
||||
/// Query settings
|
||||
#[serde(default)]
|
||||
pub query: QuerySection,
|
||||
|
||||
/// Pathfinding settings
|
||||
#[serde(default)]
|
||||
pub pathfinding: PathfindingSection,
|
||||
|
||||
/// Exploration settings
|
||||
#[serde(default)]
|
||||
pub exploration: ExplorationSection,
|
||||
|
||||
/// Persistence settings
|
||||
#[serde(default)]
|
||||
pub persistence: PersistenceSection,
|
||||
}
|
||||
|
||||
impl VastuConfig {
|
||||
/// Load configuration from a YAML file
|
||||
pub fn load(path: &Path) -> Result<Self, ConfigLoadError> {
|
||||
let contents =
|
||||
std::fs::read_to_string(path).map_err(|e| ConfigLoadError::Io(e.to_string()))?;
|
||||
Self::from_yaml(&contents)
|
||||
}
|
||||
|
||||
/// Load from default config path (configs/config.yaml)
|
||||
pub fn load_default() -> Result<Self, ConfigLoadError> {
|
||||
let path = Path::new("configs/config.yaml");
|
||||
if path.exists() {
|
||||
Self::load(path)
|
||||
} else {
|
||||
Ok(Self::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse from YAML string
|
||||
pub fn from_yaml(yaml: &str) -> Result<Self, ConfigLoadError> {
|
||||
serde_yaml::from_str(yaml).map_err(|e| ConfigLoadError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
/// Convert to MapConfig for OccupancyGridMap
|
||||
pub fn to_map_config(&self) -> MapConfig {
|
||||
MapConfig {
|
||||
grid: self.grid.to_grid_config(),
|
||||
sensor: self.sensor.to_sensor_config(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get robot footprint
|
||||
pub fn robot_footprint(&self) -> RobotFootprint {
|
||||
RobotFootprint::new(
|
||||
self.sensor.robot.radius,
|
||||
self.query.traversability.safety_margin,
|
||||
)
|
||||
}
|
||||
|
||||
/// Convert to ExplorationConfig
|
||||
pub fn to_exploration_config(&self) -> ExplorationConfig {
|
||||
ExplorationConfig {
|
||||
footprint: self.robot_footprint(),
|
||||
min_frontier_size: self.exploration.min_frontier_size,
|
||||
frontier_reached_threshold: self.exploration.frontier_reached_threshold,
|
||||
waypoint_threshold: self.exploration.waypoint_threshold,
|
||||
max_plan_distance: self.exploration.max_plan_distance,
|
||||
replan_interval: self.exploration.replan_interval,
|
||||
max_consecutive_failures: self.exploration.max_consecutive_failures,
|
||||
path_clearance_weight: self.exploration.path_clearance_weight,
|
||||
smooth_paths: self.exploration.smooth_paths,
|
||||
recovery_turn_angle: self.exploration.recovery.turn_angle,
|
||||
recovery_backup_distance: self.exploration.recovery.backup_distance,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to AStarConfig
|
||||
pub fn to_astar_config(&self) -> AStarConfig {
|
||||
AStarConfig {
|
||||
footprint: self.robot_footprint(),
|
||||
allow_diagonal: self.pathfinding.astar.allow_diagonal,
|
||||
diagonal_cost: self.pathfinding.astar.diagonal_cost,
|
||||
max_iterations: self.pathfinding.astar.max_iterations,
|
||||
clearance_weight: self.pathfinding.astar.clearance_weight,
|
||||
exploration_mode: false, // Default to safe mode
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to SmoothingConfig
|
||||
pub fn to_smoothing_config(&self) -> SmoothingConfig {
|
||||
SmoothingConfig {
|
||||
footprint: self.robot_footprint(),
|
||||
los_step_size: self.pathfinding.smoothing.los_step_size,
|
||||
max_iterations: self.pathfinding.smoothing.max_iterations,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to ExplorerConfig for VastuExplorer
|
||||
pub fn to_explorer_config(&self) -> ExplorerConfig {
|
||||
ExplorerConfig {
|
||||
grid: self.grid.to_grid_config(),
|
||||
sensor: self.sensor.to_sensor_config(),
|
||||
exploration: self.to_exploration_config(),
|
||||
..ExplorerConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create frontier detector with config settings
|
||||
pub fn frontier_detector(&self) -> FrontierDetector {
|
||||
FrontierDetector {
|
||||
min_frontier_size: self.query.frontier.min_frontier_size,
|
||||
use_8_connectivity: self.query.frontier.use_8_connectivity,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Grid configuration section
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct GridSection {
|
||||
/// Cell resolution (meters)
|
||||
#[serde(default = "default_resolution")]
|
||||
pub resolution: f32,
|
||||
|
||||
/// Initial grid width (cells)
|
||||
#[serde(default = "default_grid_size")]
|
||||
pub initial_width: usize,
|
||||
|
||||
/// Initial grid height (cells)
|
||||
#[serde(default = "default_grid_size")]
|
||||
pub initial_height: usize,
|
||||
|
||||
/// Origin mode: "center" or "corner"
|
||||
#[serde(default = "default_origin_mode")]
|
||||
pub origin_mode: String,
|
||||
|
||||
/// Origin X (for custom mode)
|
||||
#[serde(default)]
|
||||
pub origin_x: f32,
|
||||
|
||||
/// Origin Y (for custom mode)
|
||||
#[serde(default)]
|
||||
pub origin_y: f32,
|
||||
|
||||
/// Auto expand grid
|
||||
#[serde(default = "default_true")]
|
||||
pub auto_expand: bool,
|
||||
}
|
||||
|
||||
impl Default for GridSection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
resolution: 0.025,
|
||||
initial_width: 800,
|
||||
initial_height: 800,
|
||||
origin_mode: "center".to_string(),
|
||||
origin_x: 0.0,
|
||||
origin_y: 0.0,
|
||||
auto_expand: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GridSection {
|
||||
/// Convert to GridConfig
|
||||
pub fn to_grid_config(&self) -> GridConfig {
|
||||
let origin = if self.origin_mode == "corner" {
|
||||
Some(WorldPoint::new(self.origin_x, self.origin_y))
|
||||
} else {
|
||||
None // Centered
|
||||
};
|
||||
|
||||
GridConfig {
|
||||
resolution: self.resolution,
|
||||
initial_width: self.initial_width,
|
||||
initial_height: self.initial_height,
|
||||
origin,
|
||||
auto_expand: self.auto_expand,
|
||||
max_width: self.initial_width * 2,
|
||||
max_height: self.initial_height * 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sensor configuration section
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
pub struct SensorSection {
|
||||
/// LiDAR settings
|
||||
#[serde(default)]
|
||||
pub lidar: LidarSettings,
|
||||
|
||||
/// Robot geometry
|
||||
#[serde(default)]
|
||||
pub robot: RobotSettings,
|
||||
|
||||
/// Cliff sensor positions
|
||||
#[serde(default)]
|
||||
pub cliff_sensors: CliffSensorSettings,
|
||||
|
||||
/// Bumper settings
|
||||
#[serde(default)]
|
||||
pub bumper: BumperSettings,
|
||||
}
|
||||
|
||||
impl SensorSection {
|
||||
/// Convert to SensorConfig
|
||||
pub fn to_sensor_config(&self) -> SensorConfig {
|
||||
SensorConfig {
|
||||
robot_radius: self.robot.radius,
|
||||
lidar_offset: WorldPoint::new(self.lidar.offset_x, self.lidar.offset_y),
|
||||
confidence_threshold: 3,
|
||||
max_lidar_range: self.lidar.max_range,
|
||||
min_lidar_range: self.lidar.min_range,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LiDAR settings
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct LidarSettings {
|
||||
/// Offset X from robot center
|
||||
#[serde(default = "default_lidar_offset_x")]
|
||||
pub offset_x: f32,
|
||||
|
||||
/// Offset Y from robot center
|
||||
#[serde(default)]
|
||||
pub offset_y: f32,
|
||||
|
||||
/// Minimum valid range
|
||||
#[serde(default = "default_min_range")]
|
||||
pub min_range: f32,
|
||||
|
||||
/// Maximum valid range
|
||||
#[serde(default = "default_max_range")]
|
||||
pub max_range: f32,
|
||||
}
|
||||
|
||||
impl Default for LidarSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
offset_x: -0.110,
|
||||
offset_y: 0.0,
|
||||
min_range: 0.15,
|
||||
max_range: 8.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Robot geometry settings
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RobotSettings {
|
||||
/// Robot radius (meters)
|
||||
#[serde(default = "default_robot_radius")]
|
||||
pub radius: f32,
|
||||
}
|
||||
|
||||
impl Default for RobotSettings {
|
||||
fn default() -> Self {
|
||||
Self { radius: 0.17 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Cliff sensor positions
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CliffSensorSettings {
|
||||
/// Left side sensor position [x, y]
|
||||
pub left_side: [f32; 2],
|
||||
/// Left front sensor position [x, y]
|
||||
pub left_front: [f32; 2],
|
||||
/// Right front sensor position [x, y]
|
||||
pub right_front: [f32; 2],
|
||||
/// Right side sensor position [x, y]
|
||||
pub right_side: [f32; 2],
|
||||
}
|
||||
|
||||
impl Default for CliffSensorSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
left_side: [0.12, 0.10],
|
||||
left_front: [0.15, 0.05],
|
||||
right_front: [0.15, -0.05],
|
||||
right_side: [0.12, -0.10],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bumper settings
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct BumperSettings {
|
||||
/// Left bumper angle (radians from forward)
|
||||
#[serde(default = "default_left_bumper_angle")]
|
||||
pub left_angle: f32,
|
||||
|
||||
/// Right bumper angle (radians from forward)
|
||||
#[serde(default = "default_right_bumper_angle")]
|
||||
pub right_angle: f32,
|
||||
}
|
||||
|
||||
impl Default for BumperSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
left_angle: 0.5,
|
||||
right_angle: -0.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Query settings section
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
pub struct QuerySection {
|
||||
/// Frontier detection settings
|
||||
#[serde(default)]
|
||||
pub frontier: FrontierSettings,
|
||||
|
||||
/// Traversability checking settings
|
||||
#[serde(default)]
|
||||
pub traversability: TraversabilitySettings,
|
||||
}
|
||||
|
||||
/// Frontier detection settings
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct FrontierSettings {
|
||||
/// Minimum cells for valid frontier
|
||||
#[serde(default = "default_min_frontier_size")]
|
||||
pub min_frontier_size: usize,
|
||||
|
||||
/// Use 8-connected grid (vs 4-connected)
|
||||
#[serde(default = "default_true")]
|
||||
pub use_8_connectivity: bool,
|
||||
}
|
||||
|
||||
impl Default for FrontierSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_frontier_size: 5,
|
||||
use_8_connectivity: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Traversability settings
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TraversabilitySettings {
|
||||
/// Extra clearance around robot (meters)
|
||||
#[serde(default = "default_safety_margin")]
|
||||
pub safety_margin: f32,
|
||||
}
|
||||
|
||||
impl Default for TraversabilitySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
safety_margin: 0.05,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pathfinding settings section
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
pub struct PathfindingSection {
|
||||
/// A* algorithm settings
|
||||
#[serde(default)]
|
||||
pub astar: AStarSettings,
|
||||
|
||||
/// Path smoothing settings
|
||||
#[serde(default)]
|
||||
pub smoothing: SmoothingSettings,
|
||||
}
|
||||
|
||||
/// A* algorithm settings
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct AStarSettings {
|
||||
/// Enable 8-directional movement
|
||||
#[serde(default = "default_true")]
|
||||
pub allow_diagonal: bool,
|
||||
|
||||
/// Cost multiplier for diagonal moves (sqrt(2))
|
||||
#[serde(default = "default_diagonal_cost")]
|
||||
pub diagonal_cost: f32,
|
||||
|
||||
/// Maximum nodes to expand
|
||||
#[serde(default = "default_max_iterations")]
|
||||
pub max_iterations: usize,
|
||||
|
||||
/// Penalty for paths near obstacles (0=disabled)
|
||||
#[serde(default)]
|
||||
pub clearance_weight: f32,
|
||||
}
|
||||
|
||||
impl Default for AStarSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allow_diagonal: true,
|
||||
diagonal_cost: std::f32::consts::SQRT_2,
|
||||
max_iterations: 100_000,
|
||||
clearance_weight: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Path smoothing settings
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SmoothingSettings {
|
||||
/// Enable path smoothing
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Line-of-sight check step (meters)
|
||||
#[serde(default = "default_los_step")]
|
||||
pub los_step_size: f32,
|
||||
|
||||
/// Midpoint smoothing iterations
|
||||
#[serde(default = "default_smooth_iterations")]
|
||||
pub max_iterations: usize,
|
||||
}
|
||||
|
||||
impl Default for SmoothingSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
los_step_size: 0.05,
|
||||
max_iterations: 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exploration settings section
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ExplorationSection {
|
||||
/// Minimum cells for valid frontier
|
||||
#[serde(default = "default_min_frontier_size")]
|
||||
pub min_frontier_size: usize,
|
||||
|
||||
/// Distance to consider frontier reached (meters)
|
||||
#[serde(default = "default_frontier_threshold")]
|
||||
pub frontier_reached_threshold: f32,
|
||||
|
||||
/// Distance for waypoint reached (meters)
|
||||
#[serde(default = "default_waypoint_threshold")]
|
||||
pub waypoint_threshold: f32,
|
||||
|
||||
/// Maximum planning distance (meters)
|
||||
#[serde(default = "default_max_plan_distance")]
|
||||
pub max_plan_distance: f32,
|
||||
|
||||
/// Seconds between replans when blocked
|
||||
#[serde(default = "default_replan_interval")]
|
||||
pub replan_interval: f32,
|
||||
|
||||
/// Max consecutive planning failures before giving up
|
||||
#[serde(default = "default_max_failures")]
|
||||
pub max_consecutive_failures: usize,
|
||||
|
||||
/// Penalty for paths near obstacles
|
||||
#[serde(default)]
|
||||
pub path_clearance_weight: f32,
|
||||
|
||||
/// Enable path smoothing
|
||||
#[serde(default = "default_true")]
|
||||
pub smooth_paths: bool,
|
||||
|
||||
/// Recovery behavior settings
|
||||
#[serde(default)]
|
||||
pub recovery: RecoverySettings,
|
||||
}
|
||||
|
||||
impl Default for ExplorationSection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_frontier_size: 5,
|
||||
frontier_reached_threshold: 0.3,
|
||||
waypoint_threshold: 0.1,
|
||||
max_plan_distance: 5.0,
|
||||
replan_interval: 1.0,
|
||||
max_consecutive_failures: 5,
|
||||
path_clearance_weight: 0.0,
|
||||
smooth_paths: true,
|
||||
recovery: RecoverySettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recovery behavior settings
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RecoverySettings {
|
||||
/// Turn angle for recovery (radians)
|
||||
#[serde(default = "default_turn_angle")]
|
||||
pub turn_angle: f32,
|
||||
|
||||
/// Backup distance for recovery (meters)
|
||||
#[serde(default = "default_backup_distance")]
|
||||
pub backup_distance: f32,
|
||||
}
|
||||
|
||||
impl Default for RecoverySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
turn_angle: std::f32::consts::FRAC_PI_4,
|
||||
backup_distance: 0.1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persistence settings section
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct PersistenceSection {
|
||||
/// Output format: "vastu", "pgm", or "both"
|
||||
#[serde(default = "default_output_format")]
|
||||
pub output_format: String,
|
||||
|
||||
/// Output directory path
|
||||
#[serde(default = "default_output_dir")]
|
||||
pub output_dir: String,
|
||||
|
||||
/// Auto-save interval (seconds, 0=disabled)
|
||||
#[serde(default)]
|
||||
pub auto_save_interval: u32,
|
||||
}
|
||||
|
||||
impl Default for PersistenceSection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
output_format: "both".to_string(),
|
||||
output_dir: "./output".to_string(),
|
||||
auto_save_interval: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Config load error
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ConfigLoadError {
|
||||
/// I/O error
|
||||
Io(String),
|
||||
/// Parse error
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConfigLoadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ConfigLoadError::Io(msg) => write!(f, "IO error: {}", msg),
|
||||
ConfigLoadError::Parse(msg) => write!(f, "Parse error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigLoadError {}
|
||||
|
||||
// Default value functions for serde
|
||||
fn default_resolution() -> f32 {
|
||||
0.025
|
||||
}
|
||||
fn default_grid_size() -> usize {
|
||||
800
|
||||
}
|
||||
fn default_origin_mode() -> String {
|
||||
"center".to_string()
|
||||
}
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_lidar_offset_x() -> f32 {
|
||||
-0.110
|
||||
}
|
||||
fn default_min_range() -> f32 {
|
||||
0.15
|
||||
}
|
||||
fn default_max_range() -> f32 {
|
||||
8.0
|
||||
}
|
||||
fn default_robot_radius() -> f32 {
|
||||
0.17
|
||||
}
|
||||
fn default_left_bumper_angle() -> f32 {
|
||||
0.5
|
||||
}
|
||||
fn default_right_bumper_angle() -> f32 {
|
||||
-0.5
|
||||
}
|
||||
fn default_min_frontier_size() -> usize {
|
||||
5
|
||||
}
|
||||
fn default_safety_margin() -> f32 {
|
||||
0.05
|
||||
}
|
||||
fn default_diagonal_cost() -> f32 {
|
||||
std::f32::consts::SQRT_2
|
||||
}
|
||||
fn default_max_iterations() -> usize {
|
||||
100_000
|
||||
}
|
||||
fn default_los_step() -> f32 {
|
||||
0.05
|
||||
}
|
||||
fn default_smooth_iterations() -> usize {
|
||||
100
|
||||
}
|
||||
fn default_frontier_threshold() -> f32 {
|
||||
0.3
|
||||
}
|
||||
fn default_waypoint_threshold() -> f32 {
|
||||
0.1
|
||||
}
|
||||
fn default_max_plan_distance() -> f32 {
|
||||
5.0
|
||||
}
|
||||
fn default_replan_interval() -> f32 {
|
||||
1.0
|
||||
}
|
||||
fn default_max_failures() -> usize {
|
||||
5
|
||||
}
|
||||
fn default_turn_angle() -> f32 {
|
||||
std::f32::consts::FRAC_PI_4
|
||||
}
|
||||
fn default_backup_distance() -> f32 {
|
||||
0.1
|
||||
}
|
||||
fn default_output_format() -> String {
|
||||
"both".to_string()
|
||||
}
|
||||
fn default_output_dir() -> String {
|
||||
"./output".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = VastuConfig::default();
|
||||
assert_eq!(config.grid.resolution, 0.025);
|
||||
assert_eq!(config.sensor.robot.radius, 0.17);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yaml_roundtrip() {
|
||||
let config = VastuConfig::default();
|
||||
let yaml = serde_yaml::to_string(&config).unwrap();
|
||||
let parsed: VastuConfig = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(parsed.grid.resolution, config.grid.resolution);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_map_config() {
|
||||
let config = VastuConfig::default();
|
||||
let map_config = config.to_map_config();
|
||||
assert_eq!(map_config.grid.resolution, 0.025);
|
||||
assert_eq!(map_config.sensor.robot_radius, 0.17);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Default value functions for serde deserialization.
|
||||
|
||||
pub fn resolution() -> f32 {
|
||||
0.025
|
||||
}
|
||||
|
||||
pub fn grid_size() -> usize {
|
||||
800
|
||||
}
|
||||
|
||||
pub fn origin_mode() -> String {
|
||||
"center".to_string()
|
||||
}
|
||||
|
||||
pub fn enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn lidar_offset_x() -> f32 {
|
||||
-0.110
|
||||
}
|
||||
|
||||
pub fn min_range() -> f32 {
|
||||
0.15
|
||||
}
|
||||
|
||||
pub fn max_range() -> f32 {
|
||||
8.0
|
||||
}
|
||||
|
||||
pub fn robot_radius() -> f32 {
|
||||
0.17
|
||||
}
|
||||
|
||||
pub fn left_bumper_angle() -> f32 {
|
||||
0.5
|
||||
}
|
||||
|
||||
pub fn right_bumper_angle() -> f32 {
|
||||
-0.5
|
||||
}
|
||||
|
||||
pub fn min_frontier_size() -> usize {
|
||||
5
|
||||
}
|
||||
|
||||
pub fn safety_margin() -> f32 {
|
||||
0.05
|
||||
}
|
||||
|
||||
pub fn diagonal_cost() -> f32 {
|
||||
std::f32::consts::SQRT_2
|
||||
}
|
||||
|
||||
pub fn max_iterations() -> usize {
|
||||
100_000
|
||||
}
|
||||
|
||||
pub fn los_step() -> f32 {
|
||||
0.05
|
||||
}
|
||||
|
||||
pub fn smooth_iterations() -> usize {
|
||||
100
|
||||
}
|
||||
|
||||
pub fn frontier_threshold() -> f32 {
|
||||
0.3
|
||||
}
|
||||
|
||||
pub fn waypoint_threshold() -> f32 {
|
||||
0.1
|
||||
}
|
||||
|
||||
pub fn max_plan_distance() -> f32 {
|
||||
5.0
|
||||
}
|
||||
|
||||
pub fn replan_interval() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
pub fn max_failures() -> usize {
|
||||
5
|
||||
}
|
||||
|
||||
pub fn turn_angle() -> f32 {
|
||||
std::f32::consts::FRAC_PI_4
|
||||
}
|
||||
|
||||
pub fn backup_distance() -> f32 {
|
||||
0.1
|
||||
}
|
||||
|
||||
pub fn output_format() -> String {
|
||||
"both".to_string()
|
||||
}
|
||||
|
||||
pub fn output_dir() -> String {
|
||||
"./output".to_string()
|
||||
}
|
||||
|
||||
pub fn motion_max_time() -> f32 {
|
||||
5.0
|
||||
}
|
||||
|
||||
pub fn motion_max_distance() -> f32 {
|
||||
0.2
|
||||
}
|
||||
|
||||
pub fn motion_max_angle() -> f32 {
|
||||
0.17
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Configuration loading errors.
|
||||
|
||||
/// Config load error
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ConfigLoadError {
|
||||
/// I/O error
|
||||
Io(String),
|
||||
/// Parse error
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConfigLoadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ConfigLoadError::Io(msg) => write!(f, "IO error: {}", msg),
|
||||
ConfigLoadError::Parse(msg) => write!(f, "Parse error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigLoadError {}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Exploration configuration section.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::defaults;
|
||||
|
||||
/// Exploration settings section
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ExplorationSection {
|
||||
/// Minimum cells for valid frontier
|
||||
#[serde(default = "defaults::min_frontier_size")]
|
||||
pub min_frontier_size: usize,
|
||||
|
||||
/// Distance to consider frontier reached (meters)
|
||||
#[serde(default = "defaults::frontier_threshold")]
|
||||
pub frontier_reached_threshold: f32,
|
||||
|
||||
/// Distance for waypoint reached (meters)
|
||||
#[serde(default = "defaults::waypoint_threshold")]
|
||||
pub waypoint_threshold: f32,
|
||||
|
||||
/// Maximum planning distance (meters)
|
||||
#[serde(default = "defaults::max_plan_distance")]
|
||||
pub max_plan_distance: f32,
|
||||
|
||||
/// Seconds between replans when blocked
|
||||
#[serde(default = "defaults::replan_interval")]
|
||||
pub replan_interval: f32,
|
||||
|
||||
/// Max consecutive planning failures before giving up
|
||||
#[serde(default = "defaults::max_failures")]
|
||||
pub max_consecutive_failures: usize,
|
||||
|
||||
/// Penalty for paths near obstacles
|
||||
#[serde(default)]
|
||||
pub path_clearance_weight: f32,
|
||||
|
||||
/// Enable path smoothing
|
||||
#[serde(default = "defaults::enabled")]
|
||||
pub smooth_paths: bool,
|
||||
|
||||
/// Recovery behavior settings
|
||||
#[serde(default)]
|
||||
pub recovery: RecoverySettings,
|
||||
}
|
||||
|
||||
impl Default for ExplorationSection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_frontier_size: 5,
|
||||
frontier_reached_threshold: 0.3,
|
||||
waypoint_threshold: 0.1,
|
||||
max_plan_distance: 5.0,
|
||||
replan_interval: 1.0,
|
||||
max_consecutive_failures: 5,
|
||||
path_clearance_weight: 0.0,
|
||||
smooth_paths: true,
|
||||
recovery: RecoverySettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recovery behavior settings
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RecoverySettings {
|
||||
/// Turn angle for recovery (radians)
|
||||
#[serde(default = "defaults::turn_angle")]
|
||||
pub turn_angle: f32,
|
||||
|
||||
/// Backup distance for recovery (meters)
|
||||
#[serde(default = "defaults::backup_distance")]
|
||||
pub backup_distance: f32,
|
||||
}
|
||||
|
||||
impl Default for RecoverySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
turn_angle: std::f32::consts::FRAC_PI_4,
|
||||
backup_distance: 0.1,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Grid configuration section.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::WorldPoint;
|
||||
use crate::grid::GridConfig;
|
||||
|
||||
use super::defaults;
|
||||
|
||||
/// Grid configuration section
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct GridSection {
|
||||
/// Cell resolution (meters)
|
||||
#[serde(default = "defaults::resolution")]
|
||||
pub resolution: f32,
|
||||
|
||||
/// Initial grid width (cells)
|
||||
#[serde(default = "defaults::grid_size")]
|
||||
pub initial_width: usize,
|
||||
|
||||
/// Initial grid height (cells)
|
||||
#[serde(default = "defaults::grid_size")]
|
||||
pub initial_height: usize,
|
||||
|
||||
/// Origin mode: "center" or "corner"
|
||||
#[serde(default = "defaults::origin_mode")]
|
||||
pub origin_mode: String,
|
||||
|
||||
/// Origin X (for custom mode)
|
||||
#[serde(default)]
|
||||
pub origin_x: f32,
|
||||
|
||||
/// Origin Y (for custom mode)
|
||||
#[serde(default)]
|
||||
pub origin_y: f32,
|
||||
|
||||
/// Auto expand grid
|
||||
#[serde(default = "defaults::enabled")]
|
||||
pub auto_expand: bool,
|
||||
}
|
||||
|
||||
impl Default for GridSection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
resolution: 0.025,
|
||||
initial_width: 800,
|
||||
initial_height: 800,
|
||||
origin_mode: "center".to_string(),
|
||||
origin_x: 0.0,
|
||||
origin_y: 0.0,
|
||||
auto_expand: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GridSection {
|
||||
/// Convert to GridConfig
|
||||
pub fn to_grid_config(&self) -> GridConfig {
|
||||
let origin = if self.origin_mode == "corner" {
|
||||
Some(WorldPoint::new(self.origin_x, self.origin_y))
|
||||
} else {
|
||||
None // Centered
|
||||
};
|
||||
|
||||
GridConfig {
|
||||
resolution: self.resolution,
|
||||
initial_width: self.initial_width,
|
||||
initial_height: self.initial_height,
|
||||
origin,
|
||||
auto_expand: self.auto_expand,
|
||||
max_width: self.initial_width * 2,
|
||||
max_height: self.initial_height * 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Unified configuration loading for VastuMap.
|
||||
//!
|
||||
//! Loads all configuration from a single YAML file.
|
||||
|
||||
mod defaults;
|
||||
mod error;
|
||||
mod exploration;
|
||||
mod grid;
|
||||
mod motion_filter;
|
||||
mod pathfinding;
|
||||
mod persistence;
|
||||
mod query;
|
||||
mod sensor;
|
||||
mod slam;
|
||||
mod vastu;
|
||||
|
||||
// Re-export main types
|
||||
pub use error::ConfigLoadError;
|
||||
pub use vastu::VastuConfig;
|
||||
|
||||
// Re-export section types
|
||||
pub use exploration::{ExplorationSection, RecoverySettings};
|
||||
pub use grid::GridSection;
|
||||
pub use motion_filter::MotionFilterSection;
|
||||
pub use pathfinding::{AStarSettings, PathfindingSection, SmoothingSettings};
|
||||
pub use persistence::PersistenceSection;
|
||||
pub use query::{FrontierSettings, QuerySection, TraversabilitySettings};
|
||||
pub use sensor::{
|
||||
BumperSettings, CliffSensorSettings, LidarSettings, RobotSettings, SensorSection,
|
||||
};
|
||||
pub use slam::SlamSection;
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Motion filter configuration section.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::explore::MotionFilterConfig;
|
||||
|
||||
use super::defaults;
|
||||
|
||||
/// Motion filter settings section
|
||||
///
|
||||
/// Gates scan processing based on robot movement to reduce redundant observations.
|
||||
/// A scan is processed if ANY threshold is exceeded.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct MotionFilterSection {
|
||||
/// Whether motion filtering is enabled
|
||||
#[serde(default = "defaults::enabled")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Maximum time between scan insertions (seconds)
|
||||
#[serde(default = "defaults::motion_max_time")]
|
||||
pub max_time_secs: f32,
|
||||
|
||||
/// Maximum distance before forcing scan insertion (meters)
|
||||
#[serde(default = "defaults::motion_max_distance")]
|
||||
pub max_distance: f32,
|
||||
|
||||
/// Maximum rotation before forcing scan insertion (radians)
|
||||
#[serde(default = "defaults::motion_max_angle")]
|
||||
pub max_angle: f32,
|
||||
}
|
||||
|
||||
impl Default for MotionFilterSection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
max_time_secs: defaults::motion_max_time(),
|
||||
max_distance: defaults::motion_max_distance(),
|
||||
max_angle: defaults::motion_max_angle(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MotionFilterSection {
|
||||
/// Convert to MotionFilterConfig
|
||||
pub fn to_motion_filter_config(&self) -> MotionFilterConfig {
|
||||
MotionFilterConfig {
|
||||
enabled: self.enabled,
|
||||
max_time_secs: self.max_time_secs,
|
||||
max_distance: self.max_distance,
|
||||
max_angle: self.max_angle,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Pathfinding configuration section.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::defaults;
|
||||
|
||||
/// Pathfinding settings section
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
pub struct PathfindingSection {
|
||||
/// A* algorithm settings
|
||||
#[serde(default)]
|
||||
pub astar: AStarSettings,
|
||||
|
||||
/// Path smoothing settings
|
||||
#[serde(default)]
|
||||
pub smoothing: SmoothingSettings,
|
||||
}
|
||||
|
||||
/// A* algorithm settings
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct AStarSettings {
|
||||
/// Enable 8-directional movement
|
||||
#[serde(default = "defaults::enabled")]
|
||||
pub allow_diagonal: bool,
|
||||
|
||||
/// Cost multiplier for diagonal moves (sqrt(2))
|
||||
#[serde(default = "defaults::diagonal_cost")]
|
||||
pub diagonal_cost: f32,
|
||||
|
||||
/// Maximum nodes to expand
|
||||
#[serde(default = "defaults::max_iterations")]
|
||||
pub max_iterations: usize,
|
||||
|
||||
/// Penalty for paths near obstacles (0=disabled)
|
||||
#[serde(default)]
|
||||
pub clearance_weight: f32,
|
||||
}
|
||||
|
||||
impl Default for AStarSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allow_diagonal: true,
|
||||
diagonal_cost: std::f32::consts::SQRT_2,
|
||||
max_iterations: 100_000,
|
||||
clearance_weight: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Path smoothing settings
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SmoothingSettings {
|
||||
/// Enable path smoothing
|
||||
#[serde(default = "defaults::enabled")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Line-of-sight check step (meters)
|
||||
#[serde(default = "defaults::los_step")]
|
||||
pub los_step_size: f32,
|
||||
|
||||
/// Midpoint smoothing iterations
|
||||
#[serde(default = "defaults::smooth_iterations")]
|
||||
pub max_iterations: usize,
|
||||
}
|
||||
|
||||
impl Default for SmoothingSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
los_step_size: 0.05,
|
||||
max_iterations: 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Persistence configuration section.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::defaults;
|
||||
|
||||
/// Persistence settings section
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct PersistenceSection {
|
||||
/// Output format: "vastu", "pgm", or "both"
|
||||
#[serde(default = "defaults::output_format")]
|
||||
pub output_format: String,
|
||||
|
||||
/// Output directory path
|
||||
#[serde(default = "defaults::output_dir")]
|
||||
pub output_dir: String,
|
||||
|
||||
/// Auto-save interval (seconds, 0=disabled)
|
||||
#[serde(default)]
|
||||
pub auto_save_interval: u32,
|
||||
}
|
||||
|
||||
impl Default for PersistenceSection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
output_format: "both".to_string(),
|
||||
output_dir: "./output".to_string(),
|
||||
auto_save_interval: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Query configuration section.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::defaults;
|
||||
|
||||
/// Query settings section
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
pub struct QuerySection {
|
||||
/// Frontier detection settings
|
||||
#[serde(default)]
|
||||
pub frontier: FrontierSettings,
|
||||
|
||||
/// Traversability checking settings
|
||||
#[serde(default)]
|
||||
pub traversability: TraversabilitySettings,
|
||||
}
|
||||
|
||||
/// Frontier detection settings
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct FrontierSettings {
|
||||
/// Minimum cells for valid frontier
|
||||
#[serde(default = "defaults::min_frontier_size")]
|
||||
pub min_frontier_size: usize,
|
||||
|
||||
/// Use 8-connected grid (vs 4-connected)
|
||||
#[serde(default = "defaults::enabled")]
|
||||
pub use_8_connectivity: bool,
|
||||
}
|
||||
|
||||
impl Default for FrontierSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_frontier_size: 5,
|
||||
use_8_connectivity: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Traversability settings
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TraversabilitySettings {
|
||||
/// Extra clearance around robot (meters)
|
||||
#[serde(default = "defaults::safety_margin")]
|
||||
pub safety_margin: f32,
|
||||
}
|
||||
|
||||
impl Default for TraversabilitySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
safety_margin: 0.05,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Sensor configuration section.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::WorldPoint;
|
||||
use crate::grid::SensorConfig;
|
||||
|
||||
use super::defaults;
|
||||
|
||||
/// Sensor configuration section
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
pub struct SensorSection {
|
||||
/// LiDAR settings
|
||||
#[serde(default)]
|
||||
pub lidar: LidarSettings,
|
||||
|
||||
/// Robot geometry
|
||||
#[serde(default)]
|
||||
pub robot: RobotSettings,
|
||||
|
||||
/// Cliff sensor positions
|
||||
#[serde(default)]
|
||||
pub cliff_sensors: CliffSensorSettings,
|
||||
|
||||
/// Bumper settings
|
||||
#[serde(default)]
|
||||
pub bumper: BumperSettings,
|
||||
}
|
||||
|
||||
impl SensorSection {
|
||||
/// Convert to SensorConfig
|
||||
pub fn to_sensor_config(&self) -> SensorConfig {
|
||||
SensorConfig {
|
||||
robot_radius: self.robot.radius,
|
||||
lidar_offset: WorldPoint::new(self.lidar.offset_x, self.lidar.offset_y),
|
||||
confidence_threshold: 3,
|
||||
max_lidar_range: self.lidar.max_range,
|
||||
min_lidar_range: self.lidar.min_range,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LiDAR settings
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct LidarSettings {
|
||||
/// Offset X from robot center
|
||||
#[serde(default = "defaults::lidar_offset_x")]
|
||||
pub offset_x: f32,
|
||||
|
||||
/// Offset Y from robot center
|
||||
#[serde(default)]
|
||||
pub offset_y: f32,
|
||||
|
||||
/// Minimum valid range
|
||||
#[serde(default = "defaults::min_range")]
|
||||
pub min_range: f32,
|
||||
|
||||
/// Maximum valid range
|
||||
#[serde(default = "defaults::max_range")]
|
||||
pub max_range: f32,
|
||||
}
|
||||
|
||||
impl Default for LidarSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
offset_x: -0.110,
|
||||
offset_y: 0.0,
|
||||
min_range: 0.15,
|
||||
max_range: 8.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Robot geometry settings
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RobotSettings {
|
||||
/// Robot radius (meters)
|
||||
#[serde(default = "defaults::robot_radius")]
|
||||
pub radius: f32,
|
||||
}
|
||||
|
||||
impl Default for RobotSettings {
|
||||
fn default() -> Self {
|
||||
Self { radius: 0.17 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Cliff sensor positions
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CliffSensorSettings {
|
||||
/// Left side sensor position [x, y]
|
||||
pub left_side: [f32; 2],
|
||||
/// Left front sensor position [x, y]
|
||||
pub left_front: [f32; 2],
|
||||
/// Right front sensor position [x, y]
|
||||
pub right_front: [f32; 2],
|
||||
/// Right side sensor position [x, y]
|
||||
pub right_side: [f32; 2],
|
||||
}
|
||||
|
||||
impl Default for CliffSensorSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
left_side: [0.12, 0.10],
|
||||
left_front: [0.15, 0.05],
|
||||
right_front: [0.15, -0.05],
|
||||
right_side: [0.12, -0.10],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bumper settings
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct BumperSettings {
|
||||
/// Left bumper angle (radians from forward)
|
||||
#[serde(default = "defaults::left_bumper_angle")]
|
||||
pub left_angle: f32,
|
||||
|
||||
/// Right bumper angle (radians from forward)
|
||||
#[serde(default = "defaults::right_bumper_angle")]
|
||||
pub right_angle: f32,
|
||||
}
|
||||
|
||||
impl Default for BumperSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
left_angle: 0.5,
|
||||
right_angle: -0.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! SLAM configuration section.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::grid::LogOddsConfig;
|
||||
use crate::slam::{
|
||||
BackgroundOptimizerConfig, CorrelativeMatcherConfig, LoopClosureConfig, PoseGraphConfig,
|
||||
};
|
||||
|
||||
/// SLAM configuration section (Cartographer-style)
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
pub struct SlamSection {
|
||||
/// Log-odds occupancy model configuration
|
||||
#[serde(default)]
|
||||
pub log_odds: LogOddsConfig,
|
||||
|
||||
/// Correlative scan matcher configuration
|
||||
#[serde(default)]
|
||||
pub correlative: CorrelativeMatcherConfig,
|
||||
|
||||
/// Loop closure detection configuration
|
||||
#[serde(default)]
|
||||
pub loop_closure: LoopClosureConfig,
|
||||
|
||||
/// Pose graph optimization configuration
|
||||
#[serde(default)]
|
||||
pub pose_graph: PoseGraphConfig,
|
||||
|
||||
/// Background optimizer configuration
|
||||
#[serde(default)]
|
||||
pub background_optimizer: BackgroundOptimizerConfig,
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Main VastuConfig and conversion methods.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::explore::{ExplorationConfig, ExplorerConfig};
|
||||
use crate::grid::MapConfig;
|
||||
use crate::pathfinding::{AStarConfig, SmoothingConfig};
|
||||
use crate::query::{FrontierDetector, RobotFootprint};
|
||||
use crate::slam::{
|
||||
BackgroundOptimizerConfig, CorrelativeMatcherConfig, LoopClosureConfig, PoseGraphConfig,
|
||||
};
|
||||
|
||||
use super::error::ConfigLoadError;
|
||||
use super::exploration::ExplorationSection;
|
||||
use super::grid::GridSection;
|
||||
use super::motion_filter::MotionFilterSection;
|
||||
use super::pathfinding::PathfindingSection;
|
||||
use super::persistence::PersistenceSection;
|
||||
use super::query::QuerySection;
|
||||
use super::sensor::SensorSection;
|
||||
use super::slam::SlamSection;
|
||||
|
||||
/// Full VastuMap configuration loaded from YAML
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
pub struct VastuConfig {
|
||||
/// Grid settings
|
||||
#[serde(default)]
|
||||
pub grid: GridSection,
|
||||
|
||||
/// Sensor settings
|
||||
#[serde(default)]
|
||||
pub sensor: SensorSection,
|
||||
|
||||
/// SLAM settings (Cartographer-style defaults)
|
||||
#[serde(default)]
|
||||
pub slam: SlamSection,
|
||||
|
||||
/// Query settings
|
||||
#[serde(default)]
|
||||
pub query: QuerySection,
|
||||
|
||||
/// Pathfinding settings
|
||||
#[serde(default)]
|
||||
pub pathfinding: PathfindingSection,
|
||||
|
||||
/// Motion filter settings
|
||||
#[serde(default)]
|
||||
pub motion_filter: MotionFilterSection,
|
||||
|
||||
/// Exploration settings
|
||||
#[serde(default)]
|
||||
pub exploration: ExplorationSection,
|
||||
|
||||
/// Persistence settings
|
||||
#[serde(default)]
|
||||
pub persistence: PersistenceSection,
|
||||
}
|
||||
|
||||
impl VastuConfig {
|
||||
/// Load configuration from a YAML file
|
||||
pub fn load(path: &Path) -> Result<Self, ConfigLoadError> {
|
||||
let contents =
|
||||
std::fs::read_to_string(path).map_err(|e| ConfigLoadError::Io(e.to_string()))?;
|
||||
Self::from_yaml(&contents)
|
||||
}
|
||||
|
||||
/// Load from default config path (configs/config.yaml)
|
||||
pub fn load_default() -> Result<Self, ConfigLoadError> {
|
||||
let path = Path::new("configs/config.yaml");
|
||||
if path.exists() {
|
||||
Self::load(path)
|
||||
} else {
|
||||
Ok(Self::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse from YAML string
|
||||
pub fn from_yaml(yaml: &str) -> Result<Self, ConfigLoadError> {
|
||||
serde_yaml::from_str(yaml).map_err(|e| ConfigLoadError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
/// Convert to MapConfig for OccupancyGridMap
|
||||
pub fn to_map_config(&self) -> MapConfig {
|
||||
MapConfig {
|
||||
grid: self.grid.to_grid_config(),
|
||||
sensor: self.sensor.to_sensor_config(),
|
||||
log_odds: self.slam.log_odds.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the correlative matcher config
|
||||
pub fn correlative_matcher_config(&self) -> CorrelativeMatcherConfig {
|
||||
self.slam.correlative.clone()
|
||||
}
|
||||
|
||||
/// Get the loop closure config
|
||||
pub fn loop_closure_config(&self) -> LoopClosureConfig {
|
||||
self.slam.loop_closure.clone()
|
||||
}
|
||||
|
||||
/// Get the pose graph config
|
||||
pub fn pose_graph_config(&self) -> PoseGraphConfig {
|
||||
self.slam.pose_graph.clone()
|
||||
}
|
||||
|
||||
/// Get the background optimizer config
|
||||
pub fn background_optimizer_config(&self) -> BackgroundOptimizerConfig {
|
||||
self.slam.background_optimizer.clone()
|
||||
}
|
||||
|
||||
/// Get robot footprint
|
||||
pub fn robot_footprint(&self) -> RobotFootprint {
|
||||
RobotFootprint::new(
|
||||
self.sensor.robot.radius,
|
||||
self.query.traversability.safety_margin,
|
||||
)
|
||||
}
|
||||
|
||||
/// Convert to ExplorationConfig
|
||||
pub fn to_exploration_config(&self) -> ExplorationConfig {
|
||||
ExplorationConfig {
|
||||
footprint: self.robot_footprint(),
|
||||
min_frontier_size: self.exploration.min_frontier_size,
|
||||
frontier_reached_threshold: self.exploration.frontier_reached_threshold,
|
||||
waypoint_threshold: self.exploration.waypoint_threshold,
|
||||
max_plan_distance: self.exploration.max_plan_distance,
|
||||
replan_interval: self.exploration.replan_interval,
|
||||
max_consecutive_failures: self.exploration.max_consecutive_failures,
|
||||
path_clearance_weight: self.exploration.path_clearance_weight,
|
||||
smooth_paths: self.exploration.smooth_paths,
|
||||
recovery_turn_angle: self.exploration.recovery.turn_angle,
|
||||
recovery_backup_distance: self.exploration.recovery.backup_distance,
|
||||
// Use defaults for new fields
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to AStarConfig
|
||||
pub fn to_astar_config(&self) -> AStarConfig {
|
||||
AStarConfig {
|
||||
footprint: self.robot_footprint(),
|
||||
allow_diagonal: self.pathfinding.astar.allow_diagonal,
|
||||
diagonal_cost: self.pathfinding.astar.diagonal_cost,
|
||||
max_iterations: self.pathfinding.astar.max_iterations,
|
||||
clearance_weight: self.pathfinding.astar.clearance_weight,
|
||||
exploration_mode: false, // Default to safe mode
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to SmoothingConfig
|
||||
pub fn to_smoothing_config(&self) -> SmoothingConfig {
|
||||
SmoothingConfig {
|
||||
footprint: self.robot_footprint(),
|
||||
los_step_size: self.pathfinding.smoothing.los_step_size,
|
||||
max_iterations: self.pathfinding.smoothing.max_iterations,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to ExplorerConfig for VastuExplorer
|
||||
pub fn to_explorer_config(&self) -> ExplorerConfig {
|
||||
ExplorerConfig {
|
||||
grid: self.grid.to_grid_config(),
|
||||
sensor: self.sensor.to_sensor_config(),
|
||||
exploration: self.to_exploration_config(),
|
||||
motion_filter: self.motion_filter.to_motion_filter_config(),
|
||||
..ExplorerConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create frontier detector with config settings
|
||||
pub fn frontier_detector(&self) -> FrontierDetector {
|
||||
FrontierDetector {
|
||||
min_frontier_size: self.query.frontier.min_frontier_size,
|
||||
use_8_connectivity: self.query.frontier.use_8_connectivity,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = VastuConfig::default();
|
||||
assert_eq!(config.grid.resolution, 0.025);
|
||||
assert_eq!(config.sensor.robot.radius, 0.17);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yaml_roundtrip() {
|
||||
let config = VastuConfig::default();
|
||||
let yaml = serde_yaml::to_string(&config).unwrap();
|
||||
let parsed: VastuConfig = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(parsed.grid.resolution, config.grid.resolution);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_map_config() {
|
||||
let config = VastuConfig::default();
|
||||
let map_config = config.to_map_config();
|
||||
assert_eq!(map_config.grid.resolution, 0.025);
|
||||
assert_eq!(map_config.sensor.robot_radius, 0.17);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! - [`Cell`] and [`CellType`]: Grid cell representation with semantic types
|
||||
//! - [`GridCoord`] and [`WorldPoint`]: Coordinate types
|
||||
//! - [`Pose2D`]: Robot pose (position + orientation)
|
||||
//! - [`PointCloud`]: SIMD-optimized 2D point cloud (SoA layout)
|
||||
//! - Sensor types: [`LidarScan`], [`CliffSensors`], [`BumperSensors`]
|
||||
|
||||
mod cell;
|
||||
@@ -17,3 +18,4 @@ pub use cell::{Cell, CellType};
|
||||
pub use point::{GridCoord, WorldPoint};
|
||||
pub use pose::{Pose2D, interpolate_pose, normalize_angle};
|
||||
pub use sensors::{BumperSensors, CliffSensors, LidarScan, SensorObservation};
|
||||
pub use simd::{GridCoordBatch, PointCloud, RotationMatrix4};
|
||||
|
||||
+169
-110
@@ -1,85 +1,84 @@
|
||||
//! SIMD-accelerated batch operations for point transformations.
|
||||
//!
|
||||
//! This module provides vectorized operations for transforming point clouds,
|
||||
//! optimized for ARM NEON (f32x4) and x86 SSE/AVX.
|
||||
//! optimized for ARM NEON (f32x4) and x86 SSE/AVX. Uses `std::simd` which
|
||||
//! provides automatic scalar fallback on platforms without native SIMD.
|
||||
//!
|
||||
//! ## Key Types
|
||||
//!
|
||||
//! - [`PointBatch`]: Structure-of-Arrays storage for 2D points
|
||||
//! - [`PointCloud`]: Structure-of-Arrays storage for 2D points (SIMD-optimized)
|
||||
//! - [`RotationMatrix4`]: Pre-computed rotation matrix for SIMD transforms
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use vastu_map::core::simd::{PointBatch, transform_points_simd4, RotationMatrix4};
|
||||
//! use vastu_map::core::simd::{PointCloud, transform_points, RotationMatrix4};
|
||||
//!
|
||||
//! let points = PointBatch::from_tuples(&[(1.0, 0.0), (0.0, 1.0), (1.0, 1.0), (2.0, 0.0)]);
|
||||
//! // Use padded constructors to ensure SIMD alignment
|
||||
//! let points = PointCloud::from_tuples_padded(&[(1.0, 0.0), (0.0, 1.0), (1.0, 1.0)]);
|
||||
//! let pose = Pose2D::new(1.0, 2.0, 0.5);
|
||||
//! let rot = RotationMatrix4::from_pose(&pose);
|
||||
//!
|
||||
//! let mut out_xs = vec![0.0f32; 4];
|
||||
//! let mut out_ys = vec![0.0f32; 4];
|
||||
//! transform_points_simd4(&points, &rot, &mut out_xs, &mut out_ys);
|
||||
//! let result = transform_points(&points, &pose);
|
||||
//! ```
|
||||
//!
|
||||
//! ## SIMD Requirements
|
||||
//!
|
||||
//! All batch operations require inputs to be padded to multiples of [`LANES`] (4).
|
||||
//! Use the `*_padded` constructors to ensure proper alignment.
|
||||
|
||||
use std::simd::{StdFloat, cmp::SimdPartialOrd, f32x4, i32x4, num::SimdFloat};
|
||||
|
||||
use super::Pose2D;
|
||||
use super::{LidarScan, Pose2D};
|
||||
|
||||
/// Number of lanes for SIMD operations (4 for ARM NEON / SSE)
|
||||
pub const LANES: usize = 4;
|
||||
|
||||
/// Batch of 2D points in Structure-of-Arrays (SoA) layout.
|
||||
/// 2D point cloud in Structure-of-Arrays (SoA) layout for SIMD operations.
|
||||
///
|
||||
/// This layout is optimal for SIMD operations where x and y coordinates
|
||||
/// are processed independently.
|
||||
/// are processed independently. All SIMD operations require the length
|
||||
/// to be a multiple of [`LANES`] (4) - use `*_padded` constructors.
|
||||
///
|
||||
/// ## Memory Layout
|
||||
/// ```text
|
||||
/// xs: [x0, x1, x2, x3, x4, ...]
|
||||
/// ys: [y0, y1, y2, y3, y4, ...]
|
||||
/// ```
|
||||
///
|
||||
/// ## SIMD Alignment
|
||||
///
|
||||
/// For optimal performance and correctness, always use:
|
||||
/// - [`PointCloud::from_tuples_padded`] instead of `from_tuples`
|
||||
/// - [`PointCloud::from_scan_padded`] instead of `from_scan`
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PointBatch {
|
||||
pub struct PointCloud {
|
||||
/// X coordinates
|
||||
pub xs: Vec<f32>,
|
||||
/// Y coordinates
|
||||
pub ys: Vec<f32>,
|
||||
}
|
||||
|
||||
impl PointBatch {
|
||||
/// Create an empty batch.
|
||||
impl PointCloud {
|
||||
/// Create an empty point cloud.
|
||||
#[inline]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Create with specified capacity.
|
||||
/// Create with specified capacity (rounded up to multiple of LANES).
|
||||
#[inline]
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
let padded = capacity.div_ceil(LANES) * LANES;
|
||||
Self {
|
||||
xs: Vec::with_capacity(capacity),
|
||||
ys: Vec::with_capacity(capacity),
|
||||
xs: Vec::with_capacity(padded),
|
||||
ys: Vec::with_capacity(padded),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create from slice of (x, y) tuples.
|
||||
pub fn from_tuples(points: &[(f32, f32)]) -> Self {
|
||||
let n = points.len();
|
||||
let mut xs = Vec::with_capacity(n);
|
||||
let mut ys = Vec::with_capacity(n);
|
||||
for &(x, y) in points {
|
||||
xs.push(x);
|
||||
ys.push(y);
|
||||
}
|
||||
Self { xs, ys }
|
||||
}
|
||||
|
||||
/// Create with padding to next multiple of LANES.
|
||||
/// Create from slice of (x, y) tuples with padding to LANES multiple.
|
||||
///
|
||||
/// Padded values are zero (neutral for transforms).
|
||||
/// This eliminates remainder handling in SIMD loops.
|
||||
pub fn from_tuples_padded(points: &[(f32, f32)]) -> Self {
|
||||
pub fn from_tuples(points: &[(f32, f32)]) -> Self {
|
||||
let n = points.len();
|
||||
let padded_len = n.div_ceil(LANES) * LANES;
|
||||
let mut xs = vec![0.0; padded_len];
|
||||
@@ -91,14 +90,32 @@ impl PointBatch {
|
||||
Self { xs, ys }
|
||||
}
|
||||
|
||||
/// Push a point.
|
||||
/// Alias for `from_tuples` - all constructors auto-pad.
|
||||
#[inline]
|
||||
pub fn from_tuples_padded(points: &[(f32, f32)]) -> Self {
|
||||
Self::from_tuples(points)
|
||||
}
|
||||
|
||||
/// Push a point and maintain LANES alignment.
|
||||
///
|
||||
/// Note: For bulk operations, prefer constructing a new PointCloud.
|
||||
#[inline]
|
||||
pub fn push(&mut self, x: f32, y: f32) {
|
||||
self.xs.push(x);
|
||||
self.ys.push(y);
|
||||
}
|
||||
|
||||
/// Number of points.
|
||||
/// Ensure length is padded to multiple of LANES.
|
||||
pub fn pad_to_lanes(&mut self) {
|
||||
let n = self.xs.len();
|
||||
let padded_len = n.div_ceil(LANES) * LANES;
|
||||
if padded_len > n {
|
||||
self.xs.resize(padded_len, 0.0);
|
||||
self.ys.resize(padded_len, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of points (including padding).
|
||||
#[inline]
|
||||
pub fn len(&self) -> usize {
|
||||
self.xs.len()
|
||||
@@ -117,13 +134,54 @@ impl PointBatch {
|
||||
self.ys.clear();
|
||||
}
|
||||
|
||||
/// Original (unpadded) length for iteration.
|
||||
/// Check if length is SIMD-aligned (multiple of LANES).
|
||||
#[inline]
|
||||
pub fn is_aligned(&self) -> bool {
|
||||
self.xs.len().is_multiple_of(LANES)
|
||||
}
|
||||
|
||||
/// Create from a LidarScan with automatic padding.
|
||||
///
|
||||
/// Use this when the batch was created with padding.
|
||||
pub fn original_len(&self) -> usize {
|
||||
// Find last non-zero element (assuming zeros are padding)
|
||||
// This is a heuristic; for precise tracking, store original_len separately
|
||||
self.len()
|
||||
/// Points are stored as (x, y) in sensor-local coordinates:
|
||||
/// - x = range * cos(angle)
|
||||
/// - y = range * sin(angle)
|
||||
pub fn from_scan(scan: &LidarScan) -> Self {
|
||||
let valid_count = scan.valid_points().count();
|
||||
let padded_len = valid_count.div_ceil(LANES) * LANES;
|
||||
|
||||
let mut xs = vec![0.0; padded_len];
|
||||
let mut ys = vec![0.0; padded_len];
|
||||
|
||||
for (i, (angle, range)) in scan.valid_points().enumerate() {
|
||||
xs[i] = range * angle.cos();
|
||||
ys[i] = range * angle.sin();
|
||||
}
|
||||
|
||||
Self { xs, ys }
|
||||
}
|
||||
|
||||
/// Alias for `from_scan` - all constructors auto-pad.
|
||||
#[inline]
|
||||
pub fn from_scan_padded(scan: &LidarScan) -> Self {
|
||||
Self::from_scan(scan)
|
||||
}
|
||||
|
||||
/// Create from a LidarScan with subsampling and padding.
|
||||
///
|
||||
/// If max_points > 0 and scan has more points, subsample to reduce computation.
|
||||
pub fn from_scan_subsampled(scan: &LidarScan, max_points: usize) -> Self {
|
||||
let mut cloud = Self::from_scan(scan);
|
||||
|
||||
if max_points > 0 && cloud.len() > max_points {
|
||||
let step = cloud.len() / max_points;
|
||||
let new_xs: Vec<f32> = cloud.xs.iter().step_by(step).copied().collect();
|
||||
let new_ys: Vec<f32> = cloud.ys.iter().step_by(step).copied().collect();
|
||||
cloud.xs = new_xs;
|
||||
cloud.ys = new_ys;
|
||||
cloud.pad_to_lanes();
|
||||
}
|
||||
|
||||
cloud
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +269,7 @@ impl RotationMatrix4 {
|
||||
/// Transform points from sensor frame to world frame using SIMD.
|
||||
///
|
||||
/// Processes 4 points per iteration using ARM NEON / SSE instructions.
|
||||
/// On platforms without native SIMD, `std::simd` provides automatic scalar fallback.
|
||||
///
|
||||
/// # Algorithm
|
||||
/// ```text
|
||||
@@ -219,26 +278,29 @@ impl RotationMatrix4 {
|
||||
/// ```
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `points` - Input points in sensor frame (SoA layout)
|
||||
/// * `points` - Input points in sensor frame (must be LANES-aligned)
|
||||
/// * `rot` - Pre-computed rotation matrix
|
||||
/// * `out_xs` - Output X coordinates (must be at least `points.len()` long)
|
||||
/// * `out_ys` - Output Y coordinates (must be at least `points.len()` long)
|
||||
///
|
||||
/// # Panics
|
||||
/// Debug-asserts if `points.len()` is not a multiple of [`LANES`].
|
||||
#[inline]
|
||||
pub fn transform_points_simd4(
|
||||
points: &PointBatch,
|
||||
points: &PointCloud,
|
||||
rot: &RotationMatrix4,
|
||||
out_xs: &mut [f32],
|
||||
out_ys: &mut [f32],
|
||||
) {
|
||||
debug_assert_eq!(points.xs.len(), points.ys.len());
|
||||
debug_assert!(points.is_aligned(), "PointCloud must be LANES-aligned");
|
||||
debug_assert!(points.xs.len() <= out_xs.len());
|
||||
debug_assert!(points.xs.len() <= out_ys.len());
|
||||
|
||||
let n = points.xs.len();
|
||||
let simd_end = n - (n % LANES);
|
||||
|
||||
// SIMD path: process 4 points per iteration
|
||||
for i in (0..simd_end).step_by(LANES) {
|
||||
// Pure SIMD path: process 4 points per iteration
|
||||
for i in (0..n).step_by(LANES) {
|
||||
// Load 4 x and 4 y coordinates
|
||||
let px = f32x4::from_slice(&points.xs[i..]);
|
||||
let py = f32x4::from_slice(&points.ys[i..]);
|
||||
@@ -252,25 +314,12 @@ pub fn transform_points_simd4(
|
||||
world_x.copy_to_slice(&mut out_xs[i..]);
|
||||
world_y.copy_to_slice(&mut out_ys[i..]);
|
||||
}
|
||||
|
||||
// Scalar fallback for remainder
|
||||
let cos = rot.cos[0];
|
||||
let sin = rot.sin[0];
|
||||
let tx = rot.tx[0];
|
||||
let ty = rot.ty[0];
|
||||
|
||||
for i in simd_end..n {
|
||||
let px = points.xs[i];
|
||||
let py = points.ys[i];
|
||||
out_xs[i] = tx + px * cos - py * sin;
|
||||
out_ys[i] = ty + px * sin + py * cos;
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocating version of `transform_points_simd4`.
|
||||
///
|
||||
/// Returns a new `PointBatch` with the transformed points.
|
||||
pub fn transform_points(points: &PointBatch, pose: &Pose2D) -> PointBatch {
|
||||
/// Returns a new `PointCloud` with the transformed points.
|
||||
pub fn transform_points(points: &PointCloud, pose: &Pose2D) -> PointCloud {
|
||||
let n = points.xs.len();
|
||||
let mut out_xs = vec![0.0; n];
|
||||
let mut out_ys = vec![0.0; n];
|
||||
@@ -278,7 +327,7 @@ pub fn transform_points(points: &PointBatch, pose: &Pose2D) -> PointBatch {
|
||||
let rot = RotationMatrix4::from_pose(pose);
|
||||
transform_points_simd4(points, &rot, &mut out_xs, &mut out_ys);
|
||||
|
||||
PointBatch {
|
||||
PointCloud {
|
||||
xs: out_xs,
|
||||
ys: out_ys,
|
||||
}
|
||||
@@ -289,13 +338,16 @@ pub fn transform_points(points: &PointBatch, pose: &Pose2D) -> PointBatch {
|
||||
/// Performs: `grid_coord = floor((world - origin) / resolution)`
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `world_xs` - World X coordinates
|
||||
/// * `world_ys` - World Y coordinates
|
||||
/// * `world_xs` - World X coordinates (must be LANES-aligned length)
|
||||
/// * `world_ys` - World Y coordinates (must be LANES-aligned length)
|
||||
/// * `origin_x` - Grid origin X
|
||||
/// * `origin_y` - Grid origin Y
|
||||
/// * `inv_resolution` - Precomputed `1.0 / resolution`
|
||||
/// * `out_xs` - Output grid X coordinates
|
||||
/// * `out_ys` - Output grid Y coordinates
|
||||
///
|
||||
/// # Panics
|
||||
/// Debug-asserts if input length is not a multiple of [`LANES`].
|
||||
#[inline]
|
||||
pub fn world_to_grid_simd4(
|
||||
world_xs: &[f32],
|
||||
@@ -307,13 +359,17 @@ pub fn world_to_grid_simd4(
|
||||
out_ys: &mut [i32],
|
||||
) {
|
||||
let n = world_xs.len();
|
||||
let simd_end = n - (n % LANES);
|
||||
debug_assert!(
|
||||
n.is_multiple_of(LANES),
|
||||
"Input length must be LANES-aligned"
|
||||
);
|
||||
debug_assert_eq!(world_xs.len(), world_ys.len());
|
||||
|
||||
let origin_x_v = f32x4::splat(origin_x);
|
||||
let origin_y_v = f32x4::splat(origin_y);
|
||||
let inv_res_v = f32x4::splat(inv_resolution);
|
||||
|
||||
for i in (0..simd_end).step_by(LANES) {
|
||||
for i in (0..n).step_by(LANES) {
|
||||
let wx = f32x4::from_slice(&world_xs[i..]);
|
||||
let wy = f32x4::from_slice(&world_ys[i..]);
|
||||
|
||||
@@ -331,17 +387,14 @@ pub fn world_to_grid_simd4(
|
||||
gx_i32.copy_to_slice(&mut out_xs[i..i + LANES]);
|
||||
gy_i32.copy_to_slice(&mut out_ys[i..i + LANES]);
|
||||
}
|
||||
|
||||
// Scalar remainder
|
||||
for i in simd_end..n {
|
||||
out_xs[i] = ((world_xs[i] - origin_x) * inv_resolution).floor() as i32;
|
||||
out_ys[i] = ((world_ys[i] - origin_y) * inv_resolution).floor() as i32;
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate squared distances from origin for multiple points.
|
||||
///
|
||||
/// Returns `dx*dx + dy*dy` for each point (avoids sqrt for efficiency).
|
||||
///
|
||||
/// # Panics
|
||||
/// Debug-asserts if input length is not a multiple of [`LANES`].
|
||||
#[inline]
|
||||
pub fn distance_squared_simd4(
|
||||
xs: &[f32],
|
||||
@@ -351,12 +404,16 @@ pub fn distance_squared_simd4(
|
||||
out: &mut [f32],
|
||||
) {
|
||||
let n = xs.len();
|
||||
let simd_end = n - (n % LANES);
|
||||
debug_assert!(
|
||||
n.is_multiple_of(LANES),
|
||||
"Input length must be LANES-aligned"
|
||||
);
|
||||
debug_assert_eq!(xs.len(), ys.len());
|
||||
|
||||
let ox = f32x4::splat(origin_x);
|
||||
let oy = f32x4::splat(origin_y);
|
||||
|
||||
for i in (0..simd_end).step_by(LANES) {
|
||||
for i in (0..n).step_by(LANES) {
|
||||
let px = f32x4::from_slice(&xs[i..]);
|
||||
let py = f32x4::from_slice(&ys[i..]);
|
||||
|
||||
@@ -367,38 +424,37 @@ pub fn distance_squared_simd4(
|
||||
let dist_sq = dx * dx + dy * dy;
|
||||
dist_sq.copy_to_slice(&mut out[i..]);
|
||||
}
|
||||
|
||||
for i in simd_end..n {
|
||||
let dx = xs[i] - origin_x;
|
||||
let dy = ys[i] - origin_y;
|
||||
out[i] = dx * dx + dy * dy;
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate actual distances (with sqrt).
|
||||
///
|
||||
/// Only use when actual distances are needed; squared is faster.
|
||||
///
|
||||
/// # Panics
|
||||
/// Debug-asserts if input length is not a multiple of [`LANES`].
|
||||
#[inline]
|
||||
pub fn distance_simd4(xs: &[f32], ys: &[f32], origin_x: f32, origin_y: f32, out: &mut [f32]) {
|
||||
distance_squared_simd4(xs, ys, origin_x, origin_y, out);
|
||||
|
||||
let n = out.len();
|
||||
let simd_end = n - (n % LANES);
|
||||
debug_assert!(
|
||||
n.is_multiple_of(LANES),
|
||||
"Input length must be LANES-aligned"
|
||||
);
|
||||
|
||||
for i in (0..simd_end).step_by(LANES) {
|
||||
for i in (0..n).step_by(LANES) {
|
||||
let sq = f32x4::from_slice(&out[i..]);
|
||||
let dist = sq.sqrt();
|
||||
dist.copy_to_slice(&mut out[i..]);
|
||||
}
|
||||
|
||||
for val in out.iter_mut().skip(simd_end) {
|
||||
*val = val.sqrt();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if grid coordinates are within bounds (batch operation).
|
||||
///
|
||||
/// Returns a mask where `true` indicates the coordinate is valid.
|
||||
///
|
||||
/// # Panics
|
||||
/// Debug-asserts if input length is not a multiple of [`LANES`].
|
||||
#[inline]
|
||||
pub fn is_valid_coords_simd4(
|
||||
grid_xs: &[i32],
|
||||
@@ -408,13 +464,17 @@ pub fn is_valid_coords_simd4(
|
||||
out_valid: &mut [bool],
|
||||
) {
|
||||
let n = grid_xs.len();
|
||||
let simd_end = n - (n % LANES);
|
||||
debug_assert!(
|
||||
n.is_multiple_of(LANES),
|
||||
"Input length must be LANES-aligned"
|
||||
);
|
||||
debug_assert_eq!(grid_xs.len(), grid_ys.len());
|
||||
|
||||
let zero_v = i32x4::splat(0);
|
||||
let width_v = i32x4::splat(width);
|
||||
let height_v = i32x4::splat(height);
|
||||
|
||||
for i in (0..simd_end).step_by(LANES) {
|
||||
for i in (0..n).step_by(LANES) {
|
||||
let gx = i32x4::from_slice(&grid_xs[i..]);
|
||||
let gy = i32x4::from_slice(&grid_ys[i..]);
|
||||
|
||||
@@ -431,13 +491,6 @@ pub fn is_valid_coords_simd4(
|
||||
out_valid[i + j] = valid.test(j);
|
||||
}
|
||||
}
|
||||
|
||||
// Scalar remainder
|
||||
for i in simd_end..n {
|
||||
let gx = grid_xs[i];
|
||||
let gy = grid_ys[i];
|
||||
out_valid[i] = gx >= 0 && gy >= 0 && gx < width && gy < height;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -446,23 +499,26 @@ mod tests {
|
||||
use std::f32::consts::FRAC_PI_2;
|
||||
|
||||
#[test]
|
||||
fn test_point_batch_creation() {
|
||||
let points = PointBatch::from_tuples(&[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0)]);
|
||||
assert_eq!(points.len(), 3);
|
||||
fn test_point_cloud_creation() {
|
||||
// All constructors now auto-pad to LANES
|
||||
let points = PointCloud::from_tuples(&[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0)]);
|
||||
assert_eq!(points.len(), 4); // Padded to 4
|
||||
assert!(points.is_aligned());
|
||||
assert_eq!(points.xs[0], 1.0);
|
||||
assert_eq!(points.ys[0], 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_point_batch_padded() {
|
||||
let points = PointBatch::from_tuples_padded(&[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0)]);
|
||||
assert_eq!(points.len(), 4); // Padded to next multiple of 4
|
||||
assert_eq!(points.xs[3], 0.0); // Padding is zero
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_point_cloud_exact_lanes() {
|
||||
let points = PointCloud::from_tuples(&[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0), (7.0, 8.0)]);
|
||||
assert_eq!(points.len(), 4); // Exactly 4, no extra padding
|
||||
assert!(points.is_aligned());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_identity() {
|
||||
let points = PointBatch::from_tuples(&[(1.0, 0.0), (0.0, 1.0), (1.0, 1.0), (2.0, 2.0)]);
|
||||
let points = PointCloud::from_tuples(&[(1.0, 0.0), (0.0, 1.0), (1.0, 1.0), (2.0, 2.0)]);
|
||||
let pose = Pose2D::new(0.0, 0.0, 0.0);
|
||||
|
||||
let result = transform_points(&points, &pose);
|
||||
@@ -475,7 +531,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_transform_translation() {
|
||||
let points = PointBatch::from_tuples(&[(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)]);
|
||||
let points = PointCloud::from_tuples(&[(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)]);
|
||||
let pose = Pose2D::new(10.0, 20.0, 0.0);
|
||||
|
||||
let result = transform_points(&points, &pose);
|
||||
@@ -488,7 +544,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_transform_rotation_90deg() {
|
||||
let points = PointBatch::from_tuples(&[(1.0, 0.0), (0.0, 1.0), (1.0, 1.0), (0.0, 0.0)]);
|
||||
let points = PointCloud::from_tuples(&[(1.0, 0.0), (0.0, 1.0), (1.0, 1.0), (0.0, 0.0)]);
|
||||
let pose = Pose2D::new(0.0, 0.0, FRAC_PI_2);
|
||||
|
||||
let result = transform_points(&points, &pose);
|
||||
@@ -503,18 +559,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_matches_scalar() {
|
||||
fn test_transform_with_padding() {
|
||||
// Test with 5 points (will be padded to 8)
|
||||
let points =
|
||||
PointBatch::from_tuples(&[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0), (7.0, 8.0), (9.0, 10.0)]);
|
||||
PointCloud::from_tuples(&[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0), (7.0, 8.0), (9.0, 10.0)]);
|
||||
assert_eq!(points.len(), 8); // Padded to 8
|
||||
let pose = Pose2D::new(1.0, 2.0, 0.5);
|
||||
|
||||
let result = transform_points(&points, &pose);
|
||||
|
||||
// Compare with scalar transform
|
||||
// Compare with expected transform
|
||||
let cos = pose.theta.cos();
|
||||
let sin = pose.theta.sin();
|
||||
|
||||
for i in 0..points.len() {
|
||||
// Only check the first 5 (non-padding) points
|
||||
for i in 0..5 {
|
||||
let px = points.xs[i];
|
||||
let py = points.ys[i];
|
||||
let expected_x = pose.x + px * cos - py * sin;
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
//! Exploration controller configuration.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::query::RobotFootprint;
|
||||
|
||||
/// Exploration controller configuration
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ExplorationConfig {
|
||||
/// Robot footprint for collision checking
|
||||
pub footprint: RobotFootprint,
|
||||
|
||||
/// Minimum frontier size to consider (cells)
|
||||
pub min_frontier_size: usize,
|
||||
|
||||
/// Distance threshold to consider frontier reached (meters)
|
||||
pub frontier_reached_threshold: f32,
|
||||
|
||||
/// Distance threshold for waypoint reached (meters)
|
||||
pub waypoint_threshold: f32,
|
||||
|
||||
/// Maximum distance to plan at once (meters)
|
||||
pub max_plan_distance: f32,
|
||||
|
||||
/// Replan interval when path is blocked (seconds)
|
||||
pub replan_interval: f32,
|
||||
|
||||
/// Maximum consecutive failures before giving up
|
||||
pub max_consecutive_failures: usize,
|
||||
|
||||
/// Clearance weight for path planning (higher = prefer more clearance)
|
||||
pub path_clearance_weight: f32,
|
||||
|
||||
/// Enable path smoothing
|
||||
pub smooth_paths: bool,
|
||||
|
||||
/// Recovery turn angle (radians) when stuck
|
||||
pub recovery_turn_angle: f32,
|
||||
|
||||
/// Recovery backup distance (meters) when stuck
|
||||
pub recovery_backup_distance: f32,
|
||||
}
|
||||
|
||||
impl Default for ExplorationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
footprint: RobotFootprint::default(),
|
||||
min_frontier_size: 5,
|
||||
frontier_reached_threshold: 0.3, // 30cm
|
||||
waypoint_threshold: 0.1, // 10cm
|
||||
max_plan_distance: 5.0, // 5m
|
||||
replan_interval: 1.0, // 1 second
|
||||
max_consecutive_failures: 5,
|
||||
path_clearance_weight: 0.0,
|
||||
smooth_paths: true,
|
||||
recovery_turn_angle: std::f32::consts::FRAC_PI_4, // 45 degrees
|
||||
recovery_backup_distance: 0.1, // 10cm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExplorationConfig {
|
||||
/// Create with custom robot footprint
|
||||
pub fn with_footprint(footprint: RobotFootprint) -> Self {
|
||||
Self {
|
||||
footprint,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::exploration::ExplorationConfig;
|
||||
use super::controller::ExplorationConfig;
|
||||
use crate::grid::{GridConfig, MapConfig, SensorConfig};
|
||||
use crate::slam::CorrelativeMatcherConfig;
|
||||
use crate::slam::loop_closure::{LoopClosureConfig, PoseGraphConfig};
|
||||
|
||||
use super::motion_filter::MotionFilterConfig;
|
||||
|
||||
/// Configuration for the VastuExplorer.
|
||||
///
|
||||
/// This combines all the configuration needed for autonomous exploration:
|
||||
@@ -41,6 +43,11 @@ pub struct ExplorerConfig {
|
||||
#[serde(default)]
|
||||
pub pose_graph: PoseGraphConfig,
|
||||
|
||||
/// Motion filter configuration.
|
||||
/// Gates scan processing based on robot movement.
|
||||
#[serde(default)]
|
||||
pub motion_filter: MotionFilterConfig,
|
||||
|
||||
/// Control loop update rate in Hz.
|
||||
#[serde(default = "default_update_rate")]
|
||||
pub update_rate_hz: f32,
|
||||
@@ -71,6 +78,7 @@ impl Default for ExplorerConfig {
|
||||
matching: CorrelativeMatcherConfig::default(),
|
||||
loop_closure: LoopClosureConfig::default(),
|
||||
pose_graph: PoseGraphConfig::default(),
|
||||
motion_filter: MotionFilterConfig::default(),
|
||||
update_rate_hz: default_update_rate(),
|
||||
max_time_secs: 0.0,
|
||||
min_battery_percent: default_min_battery(),
|
||||
@@ -102,6 +110,7 @@ impl ExplorerConfig {
|
||||
MapConfig {
|
||||
grid: self.grid.clone(),
|
||||
sensor: self.sensor.clone(),
|
||||
log_odds: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Exploration controller configuration.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::query::RobotFootprint;
|
||||
|
||||
/// Exploration controller configuration
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ExplorationConfig {
|
||||
/// Robot footprint for collision checking.
|
||||
#[serde(default)]
|
||||
pub footprint: RobotFootprint,
|
||||
|
||||
/// Minimum frontier size to consider (cells).
|
||||
#[serde(default = "default_min_frontier_size")]
|
||||
pub min_frontier_size: usize,
|
||||
|
||||
/// Distance threshold to consider frontier reached (meters).
|
||||
#[serde(default = "default_frontier_reached_threshold")]
|
||||
pub frontier_reached_threshold: f32,
|
||||
|
||||
/// Distance threshold for waypoint reached (meters).
|
||||
#[serde(default = "default_waypoint_threshold")]
|
||||
pub waypoint_threshold: f32,
|
||||
|
||||
/// Maximum distance to plan at once (meters).
|
||||
#[serde(default = "default_max_plan_distance")]
|
||||
pub max_plan_distance: f32,
|
||||
|
||||
/// Replan interval when path is blocked (seconds).
|
||||
#[serde(default = "default_replan_interval")]
|
||||
pub replan_interval: f32,
|
||||
|
||||
/// Maximum consecutive failures before giving up.
|
||||
#[serde(default = "default_max_consecutive_failures")]
|
||||
pub max_consecutive_failures: usize,
|
||||
|
||||
/// Clearance weight for path planning (higher = prefer more clearance).
|
||||
#[serde(default)]
|
||||
pub path_clearance_weight: f32,
|
||||
|
||||
/// Enable path smoothing.
|
||||
#[serde(default = "default_smooth_paths")]
|
||||
pub smooth_paths: bool,
|
||||
|
||||
/// Recovery turn angle (radians) when stuck.
|
||||
#[serde(default = "default_recovery_turn_angle")]
|
||||
pub recovery_turn_angle: f32,
|
||||
|
||||
/// Recovery backup distance (meters) when stuck.
|
||||
#[serde(default = "default_recovery_backup_distance")]
|
||||
pub recovery_backup_distance: f32,
|
||||
|
||||
/// Maximum linear speed during navigation (m/s).
|
||||
#[serde(default = "default_navigation_max_speed")]
|
||||
pub navigation_max_speed: f32,
|
||||
|
||||
/// Maximum linear speed during recovery (m/s).
|
||||
#[serde(default = "default_recovery_max_speed")]
|
||||
pub recovery_max_speed: f32,
|
||||
|
||||
/// Angular speed during scanning rotation (rad/s).
|
||||
#[serde(default = "default_scan_angular_speed")]
|
||||
pub scan_angular_speed: f32,
|
||||
|
||||
/// Rotation step size during scanning (radians per update).
|
||||
#[serde(default = "default_scan_rotation_step")]
|
||||
pub scan_rotation_step: f32,
|
||||
|
||||
/// Distance tolerance for matching recently failed frontiers (meters).
|
||||
#[serde(default = "default_frontier_blacklist_tolerance")]
|
||||
pub frontier_blacklist_tolerance: f32,
|
||||
|
||||
/// Maximum number of steps without progress before declaring stuck.
|
||||
#[serde(default = "default_max_stuck_steps")]
|
||||
pub max_stuck_steps: usize,
|
||||
|
||||
/// Minimum progress distance to reset stuck counter (meters).
|
||||
#[serde(default = "default_min_progress_distance")]
|
||||
pub min_progress_distance: f32,
|
||||
}
|
||||
|
||||
fn default_min_frontier_size() -> usize {
|
||||
5
|
||||
}
|
||||
|
||||
fn default_frontier_reached_threshold() -> f32 {
|
||||
0.3
|
||||
}
|
||||
|
||||
fn default_waypoint_threshold() -> f32 {
|
||||
0.1
|
||||
}
|
||||
|
||||
fn default_max_plan_distance() -> f32 {
|
||||
5.0
|
||||
}
|
||||
|
||||
fn default_replan_interval() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
fn default_max_consecutive_failures() -> usize {
|
||||
5
|
||||
}
|
||||
|
||||
fn default_smooth_paths() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_recovery_turn_angle() -> f32 {
|
||||
std::f32::consts::FRAC_PI_4 // 45 degrees
|
||||
}
|
||||
|
||||
fn default_recovery_backup_distance() -> f32 {
|
||||
0.1
|
||||
}
|
||||
|
||||
fn default_navigation_max_speed() -> f32 {
|
||||
0.3 // 30 cm/s
|
||||
}
|
||||
|
||||
fn default_recovery_max_speed() -> f32 {
|
||||
0.1 // 10 cm/s
|
||||
}
|
||||
|
||||
fn default_scan_angular_speed() -> f32 {
|
||||
0.5 // rad/s
|
||||
}
|
||||
|
||||
fn default_scan_rotation_step() -> f32 {
|
||||
0.1 // ~6 degrees per step
|
||||
}
|
||||
|
||||
fn default_frontier_blacklist_tolerance() -> f32 {
|
||||
0.5 // 50 cm
|
||||
}
|
||||
|
||||
fn default_max_stuck_steps() -> usize {
|
||||
50 // ~5 seconds at 10Hz
|
||||
}
|
||||
|
||||
fn default_min_progress_distance() -> f32 {
|
||||
0.05 // 5 cm
|
||||
}
|
||||
|
||||
impl Default for ExplorationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
footprint: RobotFootprint::default(),
|
||||
min_frontier_size: default_min_frontier_size(),
|
||||
frontier_reached_threshold: default_frontier_reached_threshold(),
|
||||
waypoint_threshold: default_waypoint_threshold(),
|
||||
max_plan_distance: default_max_plan_distance(),
|
||||
replan_interval: default_replan_interval(),
|
||||
max_consecutive_failures: default_max_consecutive_failures(),
|
||||
path_clearance_weight: 0.0,
|
||||
smooth_paths: default_smooth_paths(),
|
||||
recovery_turn_angle: default_recovery_turn_angle(),
|
||||
recovery_backup_distance: default_recovery_backup_distance(),
|
||||
navigation_max_speed: default_navigation_max_speed(),
|
||||
recovery_max_speed: default_recovery_max_speed(),
|
||||
scan_angular_speed: default_scan_angular_speed(),
|
||||
scan_rotation_step: default_scan_rotation_step(),
|
||||
frontier_blacklist_tolerance: default_frontier_blacklist_tolerance(),
|
||||
max_stuck_steps: default_max_stuck_steps(),
|
||||
min_progress_distance: default_min_progress_distance(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExplorationConfig {
|
||||
/// Create with custom robot footprint
|
||||
pub fn with_footprint(footprint: RobotFootprint) -> Self {
|
||||
Self {
|
||||
footprint,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-25
@@ -140,7 +140,10 @@ impl ExplorationController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an external event
|
||||
/// Handle an external event.
|
||||
///
|
||||
/// Events are used to notify the controller of external conditions
|
||||
/// that should trigger state transitions.
|
||||
pub fn handle_event(&mut self, event: ExplorationEvent) {
|
||||
match event {
|
||||
ExplorationEvent::Start => self.start(),
|
||||
@@ -158,7 +161,6 @@ impl ExplorationController {
|
||||
ExplorationEvent::ObstacleDetected | ExplorationEvent::PathBlocked => {
|
||||
self.enter_recovery();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,10 +250,9 @@ impl ExplorationController {
|
||||
|
||||
/// Check if a frontier centroid is in the recently failed list
|
||||
fn is_recently_failed_frontier(&self, centroid: &WorldPoint) -> bool {
|
||||
const MATCH_THRESHOLD: f32 = 0.5; // 50cm tolerance
|
||||
self.failed_frontier_centroids
|
||||
.iter()
|
||||
.any(|failed| centroid.distance(failed) < MATCH_THRESHOLD)
|
||||
.any(|failed| centroid.distance(failed) < self.config.frontier_blacklist_tolerance)
|
||||
}
|
||||
|
||||
fn plan_path(
|
||||
@@ -600,7 +601,7 @@ impl ExplorationController {
|
||||
};
|
||||
return ExplorationCommand::MoveTo {
|
||||
target: next_target,
|
||||
max_speed: 0.3, // 30 cm/s default
|
||||
max_speed: self.config.navigation_max_speed,
|
||||
};
|
||||
} else {
|
||||
// Reached end of path - transition to Scanning
|
||||
@@ -630,17 +631,16 @@ impl ExplorationController {
|
||||
if angle_error < 0.5 {
|
||||
// ~30 degrees
|
||||
let progress = pose.position().distance(&self.stuck_check_position);
|
||||
if progress > 0.05 {
|
||||
// Made meaningful progress (>5cm) - reset stuck counter
|
||||
if progress > self.config.min_progress_distance {
|
||||
// Made meaningful progress - reset stuck counter
|
||||
self.steps_without_progress = 0;
|
||||
self.stuck_check_position = pose.position();
|
||||
} else {
|
||||
self.steps_without_progress += 1;
|
||||
}
|
||||
|
||||
// If stuck for too long (~5 seconds at 10Hz), trigger recovery
|
||||
const MAX_STUCK_STEPS: usize = 50;
|
||||
if self.steps_without_progress > MAX_STUCK_STEPS {
|
||||
// If stuck for too long, trigger recovery
|
||||
if self.steps_without_progress > self.config.max_stuck_steps {
|
||||
warn!(
|
||||
"[Explore] Stuck detected! {} steps without progress at ({:.2},{:.2})",
|
||||
self.steps_without_progress, pose.x, pose.y
|
||||
@@ -659,7 +659,7 @@ impl ExplorationController {
|
||||
// Continue to current waypoint
|
||||
ExplorationCommand::MoveTo {
|
||||
target: waypoint,
|
||||
max_speed: 0.3,
|
||||
max_speed: self.config.navigation_max_speed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,11 +669,9 @@ impl ExplorationController {
|
||||
rotation_remaining: f32,
|
||||
pose: Pose2D,
|
||||
) -> ExplorationCommand {
|
||||
// Rotate to scan the area - each update rotates by a fixed amount based on angular speed
|
||||
// Assuming ~0.5 rad/s angular speed and ~0.05s update rate = 0.025 rad per update
|
||||
const ROTATION_PER_STEP: f32 = 0.1; // ~6 degrees per step
|
||||
|
||||
let new_remaining = rotation_remaining - ROTATION_PER_STEP;
|
||||
// Rotate to scan the area - each update rotates by a fixed amount
|
||||
let rotation_step = self.config.scan_rotation_step;
|
||||
let new_remaining = rotation_remaining - rotation_step;
|
||||
|
||||
if new_remaining <= 0.0 {
|
||||
// Scan complete - frontier successfully explored
|
||||
@@ -693,25 +691,30 @@ impl ExplorationController {
|
||||
rotation_remaining: new_remaining,
|
||||
};
|
||||
ExplorationCommand::Rotate {
|
||||
target_heading: pose.theta + ROTATION_PER_STEP,
|
||||
max_angular_speed: 0.5,
|
||||
target_heading: pose.theta + rotation_step,
|
||||
max_angular_speed: self.config.scan_angular_speed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_recovery(&mut self, action: RecoveryAction, attempts: usize) -> ExplorationCommand {
|
||||
const MAX_RECOVERY_ATTEMPTS: usize = 3;
|
||||
|
||||
debug!(
|
||||
"[Explore] Recovery: {:?}, attempt {}/3",
|
||||
"[Explore] Recovery: {:?}, attempt {}/{}",
|
||||
action,
|
||||
attempts + 1
|
||||
attempts + 1,
|
||||
MAX_RECOVERY_ATTEMPTS
|
||||
);
|
||||
|
||||
if attempts >= 3 {
|
||||
if attempts >= MAX_RECOVERY_ATTEMPTS {
|
||||
// Too many recovery attempts
|
||||
self.consecutive_failures += 1;
|
||||
warn!(
|
||||
"[Explore] Recovery exhausted after 3 attempts, failures={}/{}",
|
||||
self.consecutive_failures, self.config.max_consecutive_failures
|
||||
"[Explore] Recovery exhausted after {} attempts, failures={}/{}",
|
||||
MAX_RECOVERY_ATTEMPTS,
|
||||
self.consecutive_failures,
|
||||
self.config.max_consecutive_failures
|
||||
);
|
||||
if self.consecutive_failures >= self.config.max_consecutive_failures {
|
||||
self.state = ExplorationState::Failed {
|
||||
@@ -735,7 +738,7 @@ impl ExplorationController {
|
||||
self.last_pose.x - distance * self.last_pose.theta.cos(),
|
||||
self.last_pose.y - distance * self.last_pose.theta.sin(),
|
||||
),
|
||||
max_speed: 0.1,
|
||||
max_speed: self.config.recovery_max_speed,
|
||||
}
|
||||
}
|
||||
RecoveryAction::TurnInPlace(angle) => {
|
||||
@@ -743,7 +746,7 @@ impl ExplorationController {
|
||||
self.state = ExplorationState::SearchingFrontiers;
|
||||
ExplorationCommand::Rotate {
|
||||
target_heading: self.last_pose.theta + angle,
|
||||
max_angular_speed: 0.5,
|
||||
max_angular_speed: self.config.scan_angular_speed,
|
||||
}
|
||||
}
|
||||
RecoveryAction::Wait => {
|
||||
@@ -9,7 +9,7 @@
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use vastu_map::exploration::{ExplorationController, ExplorationConfig};
|
||||
//! use vastu_map::explore::{ExplorationController, ExplorationConfig};
|
||||
//!
|
||||
//! let config = ExplorationConfig::default();
|
||||
//! let mut controller = ExplorationController::new(config);
|
||||
@@ -44,9 +44,9 @@
|
||||
//! 8. **Failed** - Too many errors
|
||||
|
||||
pub mod config;
|
||||
pub mod controller;
|
||||
mod machine;
|
||||
pub mod state;
|
||||
|
||||
pub use config::ExplorationConfig;
|
||||
pub use controller::{ExplorationController, ExplorationProgress};
|
||||
pub use machine::{ExplorationController, ExplorationProgress};
|
||||
pub use state::{ExplorationCommand, ExplorationEvent, ExplorationState, RecoveryAction};
|
||||
@@ -3,6 +3,8 @@
|
||||
use crate::core::WorldPoint;
|
||||
use crate::query::Frontier;
|
||||
|
||||
// Note: Frontier and WorldPoint are still used by ExplorationState and ExplorationCommand
|
||||
|
||||
/// Exploration state
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ExplorationState {
|
||||
@@ -132,51 +134,32 @@ pub enum ExplorationCommand {
|
||||
},
|
||||
}
|
||||
|
||||
/// Event that can trigger state transitions
|
||||
/// Event that can trigger state transitions.
|
||||
///
|
||||
/// These events are used to communicate external conditions to the exploration
|
||||
/// controller, allowing it to respond appropriately (e.g., entering recovery
|
||||
/// mode when an obstacle is detected).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ExplorationEvent {
|
||||
/// Start exploration
|
||||
/// Start exploration.
|
||||
Start,
|
||||
|
||||
/// Stop exploration
|
||||
/// Stop exploration and return to Idle state.
|
||||
Stop,
|
||||
|
||||
/// Resume from pause
|
||||
Resume,
|
||||
|
||||
/// Frontier detected
|
||||
FrontierFound(Frontier),
|
||||
|
||||
/// No frontiers available
|
||||
NoFrontiers,
|
||||
|
||||
/// Path planned successfully
|
||||
PathPlanned(Vec<WorldPoint>),
|
||||
|
||||
/// Path planning failed
|
||||
PlanningFailed,
|
||||
|
||||
/// Reached current waypoint
|
||||
WaypointReached,
|
||||
|
||||
/// Reached final destination
|
||||
DestinationReached,
|
||||
|
||||
/// Obstacle detected
|
||||
/// Obstacle detected by sensors (cliff, bumper, etc.).
|
||||
/// Triggers recovery behavior.
|
||||
ObstacleDetected,
|
||||
|
||||
/// Path is blocked
|
||||
/// Path is blocked and cannot continue.
|
||||
/// Triggers recovery behavior.
|
||||
PathBlocked,
|
||||
|
||||
/// Recovery completed
|
||||
/// Recovery maneuver completed successfully.
|
||||
/// Returns to frontier searching.
|
||||
RecoveryComplete,
|
||||
|
||||
/// Recovery failed
|
||||
RecoveryFailed,
|
||||
|
||||
/// Scan complete
|
||||
/// Scan rotation completed at frontier.
|
||||
/// Increments frontiers explored and searches for next.
|
||||
ScanComplete,
|
||||
|
||||
/// Timeout
|
||||
Timeout,
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Exploration error types.
|
||||
//!
|
||||
//! Provides structured error types for exploration failures,
|
||||
//! replacing String-based error messages.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::core::WorldPoint;
|
||||
|
||||
/// Errors that can occur during autonomous exploration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ExplorationError {
|
||||
/// Robot connection was lost during exploration.
|
||||
ConnectionLost,
|
||||
|
||||
/// Battery level dropped below minimum threshold.
|
||||
LowBattery {
|
||||
/// Current battery percentage.
|
||||
percent: u8,
|
||||
/// Configured minimum threshold.
|
||||
threshold: u8,
|
||||
},
|
||||
|
||||
/// Exploration exceeded the configured time limit.
|
||||
TimeoutExceeded {
|
||||
/// Elapsed time in seconds.
|
||||
elapsed_secs: f32,
|
||||
/// Configured limit in seconds.
|
||||
limit_secs: f32,
|
||||
},
|
||||
|
||||
/// Too many consecutive planning/navigation failures.
|
||||
TooManyFailures {
|
||||
/// Number of consecutive failures.
|
||||
count: usize,
|
||||
/// Configured maximum.
|
||||
max: usize,
|
||||
},
|
||||
|
||||
/// Robot appears to be stuck and recovery failed.
|
||||
StuckRecoveryFailed {
|
||||
/// Position where robot got stuck.
|
||||
position: WorldPoint,
|
||||
/// Number of recovery attempts made.
|
||||
attempts: usize,
|
||||
},
|
||||
|
||||
/// Localization drift detected (robot position appears blocked).
|
||||
LocalizationDrift {
|
||||
/// Number of consecutive "start blocked" events.
|
||||
consecutive_events: usize,
|
||||
},
|
||||
|
||||
/// No valid frontiers found with insufficient exploration.
|
||||
InsufficientExploration {
|
||||
/// Explored floor area in square meters.
|
||||
explored_area_m2: f32,
|
||||
/// Number of frontiers successfully explored.
|
||||
frontiers_explored: usize,
|
||||
},
|
||||
|
||||
/// Custom error with message.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl ExplorationError {
|
||||
/// Create an error from a string message (for backwards compatibility).
|
||||
pub fn from_message(msg: impl Into<String>) -> Self {
|
||||
Self::Other(msg.into())
|
||||
}
|
||||
|
||||
/// Get a short error code for logging/metrics.
|
||||
pub fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ConnectionLost => "CONNECTION_LOST",
|
||||
Self::LowBattery { .. } => "LOW_BATTERY",
|
||||
Self::TimeoutExceeded { .. } => "TIMEOUT",
|
||||
Self::TooManyFailures { .. } => "TOO_MANY_FAILURES",
|
||||
Self::StuckRecoveryFailed { .. } => "STUCK",
|
||||
Self::LocalizationDrift { .. } => "LOCALIZATION_DRIFT",
|
||||
Self::InsufficientExploration { .. } => "INSUFFICIENT_EXPLORATION",
|
||||
Self::Other(_) => "OTHER",
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this error is recoverable (exploration could potentially continue).
|
||||
pub fn is_recoverable(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::StuckRecoveryFailed { .. } | Self::LocalizationDrift { .. }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ExplorationError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::ConnectionLost => write!(f, "Robot connection lost"),
|
||||
Self::LowBattery { percent, threshold } => {
|
||||
write!(f, "Battery low: {}% (minimum: {}%)", percent, threshold)
|
||||
}
|
||||
Self::TimeoutExceeded {
|
||||
elapsed_secs,
|
||||
limit_secs,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"Time limit exceeded: {:.1}s elapsed (limit: {:.1}s)",
|
||||
elapsed_secs, limit_secs
|
||||
)
|
||||
}
|
||||
Self::TooManyFailures { count, max } => {
|
||||
write!(f, "Too many consecutive failures: {} (max: {})", count, max)
|
||||
}
|
||||
Self::StuckRecoveryFailed { position, attempts } => {
|
||||
write!(
|
||||
f,
|
||||
"Stuck at ({:.2}, {:.2}) after {} recovery attempts",
|
||||
position.x, position.y, attempts
|
||||
)
|
||||
}
|
||||
Self::LocalizationDrift { consecutive_events } => {
|
||||
write!(
|
||||
f,
|
||||
"Localization drift detected ({} consecutive blocked events)",
|
||||
consecutive_events
|
||||
)
|
||||
}
|
||||
Self::InsufficientExploration {
|
||||
explored_area_m2,
|
||||
frontiers_explored,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"Insufficient exploration: {:.2}m² explored, {} frontiers done",
|
||||
explored_area_m2, frontiers_explored
|
||||
)
|
||||
}
|
||||
Self::Other(msg) => write!(f, "{}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ExplorationError {}
|
||||
|
||||
/// Convert from String for backwards compatibility.
|
||||
impl From<String> for ExplorationError {
|
||||
fn from(s: String) -> Self {
|
||||
Self::Other(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for ExplorationError {
|
||||
fn from(s: &str) -> Self {
|
||||
Self::Other(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_error_display() {
|
||||
let err = ExplorationError::LowBattery {
|
||||
percent: 15,
|
||||
threshold: 20,
|
||||
};
|
||||
assert_eq!(err.to_string(), "Battery low: 15% (minimum: 20%)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_code() {
|
||||
assert_eq!(ExplorationError::ConnectionLost.code(), "CONNECTION_LOST");
|
||||
assert_eq!(
|
||||
ExplorationError::TooManyFailures { count: 5, max: 5 }.code(),
|
||||
"TOO_MANY_FAILURES"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_string() {
|
||||
let err: ExplorationError = "Custom error".into();
|
||||
assert!(matches!(err, ExplorationError::Other(_)));
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,20 @@
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use super::controller::{
|
||||
ExplorationCommand, ExplorationController, ExplorationEvent, ExplorationProgress,
|
||||
};
|
||||
use super::error::ExplorationError;
|
||||
use super::velocity::{
|
||||
VelocityConfig, compute_angular_velocity_to_heading, compute_velocity_to_target,
|
||||
};
|
||||
use crate::OccupancyGridMap;
|
||||
use crate::core::Pose2D;
|
||||
use crate::exploration::{ExplorationCommand, ExplorationController, ExplorationProgress};
|
||||
use crate::slam::CorrelativeMatcher;
|
||||
use crate::slam::loop_closure::{LoopClosureDetector, PoseGraph};
|
||||
|
||||
use super::config::ExplorerConfig;
|
||||
use super::motion_filter::MotionFilter;
|
||||
use super::source::SensorSource;
|
||||
|
||||
/// Result of a completed exploration.
|
||||
@@ -19,11 +26,34 @@ pub enum ExploreResult {
|
||||
Failed {
|
||||
/// Partial map collected before failure.
|
||||
map: OccupancyGridMap,
|
||||
/// Reason for failure.
|
||||
reason: String,
|
||||
/// Error that caused the failure.
|
||||
error: ExplorationError,
|
||||
},
|
||||
}
|
||||
|
||||
impl ExploreResult {
|
||||
/// Check if exploration completed successfully.
|
||||
pub fn is_complete(&self) -> bool {
|
||||
matches!(self, Self::Complete(_))
|
||||
}
|
||||
|
||||
/// Get the map regardless of success/failure.
|
||||
pub fn into_map(self) -> OccupancyGridMap {
|
||||
match self {
|
||||
Self::Complete(map) => map,
|
||||
Self::Failed { map, .. } => map,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the error if exploration failed.
|
||||
pub fn error(&self) -> Option<&ExplorationError> {
|
||||
match self {
|
||||
Self::Complete(_) => None,
|
||||
Self::Failed { error, .. } => Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Status returned from a single exploration step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ExploreStatus {
|
||||
@@ -36,8 +66,8 @@ pub enum ExploreStatus {
|
||||
Complete,
|
||||
/// Exploration failed.
|
||||
Failed {
|
||||
/// Reason for failure.
|
||||
reason: String,
|
||||
/// Error that caused the failure.
|
||||
error: ExplorationError,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -75,6 +105,10 @@ pub struct VastuExplorer {
|
||||
loop_detector: LoopClosureDetector,
|
||||
/// Pose graph for optimization.
|
||||
pose_graph: PoseGraph,
|
||||
/// Motion filter for gating scan processing.
|
||||
motion_filter: MotionFilter,
|
||||
/// Velocity computation configuration.
|
||||
velocity_config: VelocityConfig,
|
||||
/// Last corrected pose.
|
||||
last_pose: Pose2D,
|
||||
/// Last odometry pose (for delta calculation).
|
||||
@@ -95,6 +129,8 @@ impl VastuExplorer {
|
||||
let matcher = CorrelativeMatcher::new(config.matching.clone());
|
||||
let loop_detector = LoopClosureDetector::new(config.loop_closure.clone());
|
||||
let pose_graph = PoseGraph::new(config.pose_graph.clone());
|
||||
let motion_filter = MotionFilter::new(config.motion_filter.clone());
|
||||
let velocity_config = VelocityConfig::default();
|
||||
|
||||
Self {
|
||||
config,
|
||||
@@ -103,6 +139,8 @@ impl VastuExplorer {
|
||||
matcher,
|
||||
loop_detector,
|
||||
pose_graph,
|
||||
motion_filter,
|
||||
velocity_config,
|
||||
last_pose: Pose2D::default(),
|
||||
last_odom_pose: None,
|
||||
start_time: None,
|
||||
@@ -150,7 +188,7 @@ impl VastuExplorer {
|
||||
///
|
||||
/// # Returns
|
||||
/// `ExploreResult::Complete` with the finished map, or
|
||||
/// `ExploreResult::Failed` with a partial map and reason.
|
||||
/// `ExploreResult::Failed` with a partial map and error.
|
||||
pub fn explore<S: SensorSource>(mut self, source: &mut S) -> ExploreResult {
|
||||
// Start exploration
|
||||
self.controller.start();
|
||||
@@ -168,7 +206,7 @@ impl VastuExplorer {
|
||||
source.stop();
|
||||
return ExploreResult::Failed {
|
||||
map: self.map,
|
||||
reason: "Robot connection lost".to_string(),
|
||||
error: ExplorationError::ConnectionLost,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,16 +217,23 @@ impl VastuExplorer {
|
||||
source.stop();
|
||||
return ExploreResult::Failed {
|
||||
map: self.map,
|
||||
reason: format!("Battery low: {}%", battery),
|
||||
error: ExplorationError::LowBattery {
|
||||
percent: battery,
|
||||
threshold: self.config.min_battery_percent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Check time limit
|
||||
if self.config.max_time_secs > 0.0 && self.elapsed_secs() > self.config.max_time_secs {
|
||||
let elapsed = self.elapsed_secs();
|
||||
if self.config.max_time_secs > 0.0 && elapsed > self.config.max_time_secs {
|
||||
source.stop();
|
||||
return ExploreResult::Failed {
|
||||
map: self.map,
|
||||
reason: "Time limit exceeded".to_string(),
|
||||
error: ExplorationError::TimeoutExceeded {
|
||||
elapsed_secs: elapsed,
|
||||
limit_secs: self.config.max_time_secs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -201,11 +246,11 @@ impl VastuExplorer {
|
||||
source.stop();
|
||||
return ExploreResult::Complete(self.map);
|
||||
}
|
||||
ExploreStatus::Failed { reason } => {
|
||||
ExploreStatus::Failed { error } => {
|
||||
source.stop();
|
||||
return ExploreResult::Failed {
|
||||
map: self.map,
|
||||
reason,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -252,34 +297,44 @@ impl VastuExplorer {
|
||||
});
|
||||
self.last_odom_pose = Some(odom_pose);
|
||||
|
||||
// Process lidar scan if available
|
||||
// Process lidar scan if available and motion filter allows
|
||||
if let Some(scan) = source.get_lidar_scan() {
|
||||
// Scan matching
|
||||
let match_result = self
|
||||
.matcher
|
||||
.match_scan(&scan, odom_pose, self.map.storage());
|
||||
// Check motion filter - only process if sufficient motion occurred
|
||||
if self.motion_filter.should_process(odom_pose) {
|
||||
// Scan matching
|
||||
let match_result = self
|
||||
.matcher
|
||||
.match_scan(&scan, odom_pose, self.map.storage());
|
||||
|
||||
// Use matched pose if good, else use odometry
|
||||
let corrected_pose = if match_result.converged {
|
||||
match_result.pose
|
||||
} else {
|
||||
odom_pose
|
||||
};
|
||||
// Use matched pose if good, else use odometry
|
||||
let corrected_pose = if match_result.converged {
|
||||
match_result.pose
|
||||
} else {
|
||||
odom_pose
|
||||
};
|
||||
|
||||
// Integrate scan into map
|
||||
self.map.observe_lidar(&scan, corrected_pose);
|
||||
// Integrate scan into map
|
||||
self.map.observe_lidar(&scan, corrected_pose);
|
||||
|
||||
// Update pose graph
|
||||
self.pose_graph.add_pose(corrected_pose, odom_delta);
|
||||
// Update pose graph
|
||||
self.pose_graph.add_pose(corrected_pose, odom_delta);
|
||||
|
||||
// Check for loop closure
|
||||
if let Some(closure) = self.loop_detector.add_scan(&scan, corrected_pose) {
|
||||
self.pose_graph.add_loop_closure(closure);
|
||||
// Optimize when loop detected
|
||||
self.pose_graph.optimize();
|
||||
// Check for loop closure
|
||||
if let Some(closure) = self.loop_detector.add_scan(&scan, corrected_pose) {
|
||||
self.pose_graph.add_loop_closure(closure);
|
||||
// Optimize when loop detected
|
||||
self.pose_graph.optimize();
|
||||
}
|
||||
|
||||
self.last_pose = corrected_pose;
|
||||
} else if let Some(delta) = odom_delta {
|
||||
// Scan filtered out - update pose from odometry delta only
|
||||
self.last_pose = Pose2D::new(
|
||||
self.last_pose.x + delta.x,
|
||||
self.last_pose.y + delta.y,
|
||||
self.last_pose.theta + delta.theta,
|
||||
);
|
||||
}
|
||||
|
||||
self.last_pose = corrected_pose;
|
||||
} else if let Some(delta) = odom_delta {
|
||||
// No scan available - update pose from odometry delta
|
||||
// This prevents the controller from using stale positions
|
||||
@@ -293,11 +348,11 @@ impl VastuExplorer {
|
||||
// Handle cliff/bumper events
|
||||
if source.is_cliff_detected() {
|
||||
self.controller
|
||||
.handle_event(crate::exploration::ExplorationEvent::ObstacleDetected);
|
||||
.handle_event(ExplorationEvent::ObstacleDetected);
|
||||
}
|
||||
if source.is_bumper_triggered() {
|
||||
self.controller
|
||||
.handle_event(crate::exploration::ExplorationEvent::ObstacleDetected);
|
||||
.handle_event(ExplorationEvent::ObstacleDetected);
|
||||
}
|
||||
|
||||
// Update exploration controller
|
||||
@@ -306,18 +361,23 @@ impl VastuExplorer {
|
||||
// Execute command
|
||||
match command {
|
||||
ExplorationCommand::MoveTo { target, max_speed } => {
|
||||
let (linear, angular) =
|
||||
self.compute_velocity(self.last_pose, target.x, target.y, max_speed);
|
||||
let (linear, angular) = compute_velocity_to_target(
|
||||
self.last_pose,
|
||||
target,
|
||||
max_speed,
|
||||
&self.velocity_config,
|
||||
);
|
||||
source.send_velocity(linear, angular);
|
||||
}
|
||||
ExplorationCommand::Rotate {
|
||||
target_heading,
|
||||
max_angular_speed,
|
||||
} => {
|
||||
let angular = self.compute_angular_velocity(
|
||||
let angular = compute_angular_velocity_to_heading(
|
||||
self.last_pose.theta,
|
||||
target_heading,
|
||||
max_angular_speed,
|
||||
&self.velocity_config,
|
||||
);
|
||||
source.send_velocity(0.0, angular);
|
||||
}
|
||||
@@ -330,7 +390,9 @@ impl VastuExplorer {
|
||||
}
|
||||
ExplorationCommand::ExplorationFailed { reason } => {
|
||||
source.stop();
|
||||
return ExploreStatus::Failed { reason };
|
||||
return ExploreStatus::Failed {
|
||||
error: ExplorationError::from_message(reason),
|
||||
};
|
||||
}
|
||||
ExplorationCommand::None => {
|
||||
// Continue with current velocity or stop
|
||||
@@ -342,48 +404,6 @@ impl VastuExplorer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute velocity command to move towards target.
|
||||
fn compute_velocity(
|
||||
&self,
|
||||
pose: Pose2D,
|
||||
target_x: f32,
|
||||
target_y: f32,
|
||||
max_speed: f32,
|
||||
) -> (f32, f32) {
|
||||
let dx = target_x - pose.x;
|
||||
let dy = target_y - pose.y;
|
||||
let distance = (dx * dx + dy * dy).sqrt();
|
||||
|
||||
if distance < 0.05 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
|
||||
let target_angle = dy.atan2(dx);
|
||||
let angle_error = normalize_angle(target_angle - pose.theta);
|
||||
|
||||
// Turn towards target first if angle is large
|
||||
if angle_error.abs() > 0.3 {
|
||||
let angular = angle_error.signum() * 2.0f32.min(angle_error.abs() * 2.0);
|
||||
return (0.0, angular);
|
||||
}
|
||||
|
||||
// Move forward with proportional angular correction
|
||||
let linear = max_speed.min(distance);
|
||||
let angular = (angle_error * 2.0).clamp(-2.0, 2.0);
|
||||
|
||||
(linear, angular)
|
||||
}
|
||||
|
||||
/// Compute angular velocity to rotate to target heading.
|
||||
fn compute_angular_velocity(&self, current: f32, target: f32, max_speed: f32) -> f32 {
|
||||
let error = normalize_angle(target - current);
|
||||
if error.abs() < 0.05 {
|
||||
0.0
|
||||
} else {
|
||||
(error * 2.0).clamp(-max_speed, max_speed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset explorer for a new exploration run.
|
||||
pub fn reset(&mut self) {
|
||||
let map_config = self.config.to_map_config();
|
||||
@@ -391,27 +411,29 @@ impl VastuExplorer {
|
||||
self.controller = ExplorationController::new(self.config.exploration.clone());
|
||||
self.loop_detector.reset();
|
||||
self.pose_graph.reset();
|
||||
self.motion_filter.reset();
|
||||
self.last_pose = Pose2D::default();
|
||||
self.last_odom_pose = None;
|
||||
self.start_time = None;
|
||||
self.started = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize angle to [-π, π).
|
||||
pub fn normalize_angle(angle: f32) -> f32 {
|
||||
let mut a = angle % std::f32::consts::TAU;
|
||||
if a > std::f32::consts::PI {
|
||||
a -= std::f32::consts::TAU;
|
||||
} else if a < -std::f32::consts::PI {
|
||||
a += std::f32::consts::TAU;
|
||||
/// Get motion filter statistics.
|
||||
///
|
||||
/// Returns (passed_count, filtered_count, efficiency).
|
||||
pub fn motion_filter_stats(&self) -> (u64, u64, f32) {
|
||||
(
|
||||
self.motion_filter.passed_count(),
|
||||
self.motion_filter.filtered_count(),
|
||||
self.motion_filter.filter_efficiency(),
|
||||
)
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::normalize_angle;
|
||||
|
||||
#[test]
|
||||
fn test_explorer_creation() {
|
||||
|
||||
@@ -30,11 +30,26 @@
|
||||
//! ```
|
||||
|
||||
mod config;
|
||||
pub mod controller;
|
||||
mod error;
|
||||
mod explorer;
|
||||
mod motion_filter;
|
||||
mod source;
|
||||
mod submap_explorer;
|
||||
mod velocity;
|
||||
|
||||
pub use config::ExplorerConfig;
|
||||
pub use explorer::{ExploreResult, ExploreStatus, VastuExplorer, normalize_angle};
|
||||
pub use error::ExplorationError;
|
||||
pub use explorer::{ExploreResult, ExploreStatus, VastuExplorer};
|
||||
pub use motion_filter::{MotionFilter, MotionFilterConfig};
|
||||
pub use source::SensorSource;
|
||||
pub use submap_explorer::{SubmapExplorerConfig, VastuSubmapExplorer};
|
||||
pub use velocity::{
|
||||
VelocityConfig, compute_angular_velocity_to_heading, compute_velocity_to_target,
|
||||
};
|
||||
|
||||
// Re-export controller types for unified API
|
||||
pub use controller::{
|
||||
ExplorationCommand, ExplorationConfig, ExplorationController, ExplorationEvent,
|
||||
ExplorationProgress, ExplorationState, RecoveryAction,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
//! Motion filter for gating scan processing based on robot movement.
|
||||
//!
|
||||
//! The motion filter prevents redundant scan processing when the robot is stationary
|
||||
//! or moving slowly. A scan is only processed if sufficient time, distance, or rotation
|
||||
//! has occurred since the last processed scan.
|
||||
//!
|
||||
//! This improves CPU efficiency and ensures consistent submap sizes regardless of
|
||||
//! robot speed.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::{Pose2D, normalize_angle};
|
||||
|
||||
/// Configuration for the motion filter.
|
||||
///
|
||||
/// A scan passes the filter (should be processed) if ANY threshold is exceeded:
|
||||
/// - Time since last scan > max_time_secs
|
||||
/// - Distance traveled > max_distance
|
||||
/// - Rotation > max_angle
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct MotionFilterConfig {
|
||||
/// Maximum time between scan insertions (seconds).
|
||||
/// Ensures at least some updates even when stationary.
|
||||
/// Default: 5.0s (from Google Cartographer)
|
||||
#[serde(default = "default_max_time")]
|
||||
pub max_time_secs: f32,
|
||||
|
||||
/// Maximum distance before forcing scan insertion (meters).
|
||||
/// Default: 0.2m (20cm, from Google Cartographer)
|
||||
#[serde(default = "default_max_distance")]
|
||||
pub max_distance: f32,
|
||||
|
||||
/// Maximum rotation before forcing scan insertion (radians).
|
||||
/// Default: 0.17 rad (~10°, from Google Cartographer)
|
||||
#[serde(default = "default_max_angle")]
|
||||
pub max_angle: f32,
|
||||
|
||||
/// Whether the motion filter is enabled.
|
||||
/// When disabled, all scans pass through.
|
||||
/// Default: true
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
fn default_max_time() -> f32 {
|
||||
5.0
|
||||
}
|
||||
|
||||
fn default_max_distance() -> f32 {
|
||||
0.2
|
||||
}
|
||||
|
||||
fn default_max_angle() -> f32 {
|
||||
0.17
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for MotionFilterConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_time_secs: default_max_time(),
|
||||
max_distance: default_max_distance(),
|
||||
max_angle: default_max_angle(),
|
||||
enabled: default_enabled(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MotionFilterConfig {
|
||||
/// Create a permissive filter that processes more scans.
|
||||
/// Useful for high-quality mapping at the cost of CPU.
|
||||
pub fn permissive() -> Self {
|
||||
Self {
|
||||
max_time_secs: 2.0,
|
||||
max_distance: 0.1,
|
||||
max_angle: 0.08,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a strict filter that processes fewer scans.
|
||||
/// Useful for CPU-constrained systems.
|
||||
pub fn strict() -> Self {
|
||||
Self {
|
||||
max_time_secs: 10.0,
|
||||
max_distance: 0.3,
|
||||
max_angle: 0.25,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a disabled filter (all scans pass through).
|
||||
pub fn disabled() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Motion filter for gating scan processing.
|
||||
///
|
||||
/// The filter tracks the last processed pose and time, and determines
|
||||
/// whether a new scan should be processed based on the configured thresholds.
|
||||
pub struct MotionFilter {
|
||||
/// Pose when last scan was processed
|
||||
last_pose: Option<Pose2D>,
|
||||
/// Time when last scan was processed
|
||||
last_time: Option<Instant>,
|
||||
/// Configuration
|
||||
config: MotionFilterConfig,
|
||||
/// Count of scans that passed the filter
|
||||
passed_count: u64,
|
||||
/// Count of scans that were filtered out
|
||||
filtered_count: u64,
|
||||
}
|
||||
|
||||
impl MotionFilter {
|
||||
/// Create a new motion filter with the given configuration.
|
||||
pub fn new(config: MotionFilterConfig) -> Self {
|
||||
Self {
|
||||
last_pose: None,
|
||||
last_time: None,
|
||||
config,
|
||||
passed_count: 0,
|
||||
filtered_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a scan at the given pose should be processed.
|
||||
///
|
||||
/// Returns true if the scan should be processed, false if it should be skipped.
|
||||
/// Updates internal state when returning true.
|
||||
pub fn should_process(&mut self, pose: Pose2D) -> bool {
|
||||
// If disabled, always process
|
||||
if !self.config.enabled {
|
||||
self.passed_count += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
|
||||
// First scan always passes
|
||||
let (Some(last_pose), Some(last_time)) = (self.last_pose, self.last_time) else {
|
||||
self.last_pose = Some(pose);
|
||||
self.last_time = Some(now);
|
||||
self.passed_count += 1;
|
||||
return true;
|
||||
};
|
||||
|
||||
// Check time threshold
|
||||
let dt = now.duration_since(last_time).as_secs_f32();
|
||||
if dt > self.config.max_time_secs {
|
||||
self.last_pose = Some(pose);
|
||||
self.last_time = Some(now);
|
||||
self.passed_count += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check distance threshold
|
||||
let dx = pose.x - last_pose.x;
|
||||
let dy = pose.y - last_pose.y;
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
if dist > self.config.max_distance {
|
||||
self.last_pose = Some(pose);
|
||||
self.last_time = Some(now);
|
||||
self.passed_count += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check angle threshold
|
||||
let dtheta = normalize_angle(pose.theta - last_pose.theta).abs();
|
||||
if dtheta > self.config.max_angle {
|
||||
self.last_pose = Some(pose);
|
||||
self.last_time = Some(now);
|
||||
self.passed_count += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
// All thresholds within limits, filter this scan
|
||||
self.filtered_count += 1;
|
||||
false
|
||||
}
|
||||
|
||||
/// Reset the filter state.
|
||||
///
|
||||
/// Call this when starting a new exploration session.
|
||||
pub fn reset(&mut self) {
|
||||
self.last_pose = None;
|
||||
self.last_time = None;
|
||||
self.passed_count = 0;
|
||||
self.filtered_count = 0;
|
||||
}
|
||||
|
||||
/// Get the number of scans that passed the filter.
|
||||
pub fn passed_count(&self) -> u64 {
|
||||
self.passed_count
|
||||
}
|
||||
|
||||
/// Get the number of scans that were filtered out.
|
||||
pub fn filtered_count(&self) -> u64 {
|
||||
self.filtered_count
|
||||
}
|
||||
|
||||
/// Get the filter efficiency (fraction of scans filtered out).
|
||||
pub fn filter_efficiency(&self) -> f32 {
|
||||
let total = self.passed_count + self.filtered_count;
|
||||
if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
self.filtered_count as f32 / total as f32
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the configuration.
|
||||
pub fn config(&self) -> &MotionFilterConfig {
|
||||
&self.config
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_first_scan_always_passes() {
|
||||
let mut filter = MotionFilter::new(MotionFilterConfig::default());
|
||||
let pose = Pose2D::new(0.0, 0.0, 0.0);
|
||||
assert!(filter.should_process(pose));
|
||||
assert_eq!(filter.passed_count(), 1);
|
||||
assert_eq!(filter.filtered_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stationary_robot_filtered() {
|
||||
let mut filter = MotionFilter::new(MotionFilterConfig::default());
|
||||
let pose = Pose2D::new(1.0, 2.0, 0.5);
|
||||
|
||||
// First scan passes
|
||||
assert!(filter.should_process(pose));
|
||||
|
||||
// Same pose should be filtered
|
||||
assert!(!filter.should_process(pose));
|
||||
assert!(!filter.should_process(pose));
|
||||
|
||||
assert_eq!(filter.passed_count(), 1);
|
||||
assert_eq!(filter.filtered_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_distance_threshold() {
|
||||
let mut filter = MotionFilter::new(MotionFilterConfig::default());
|
||||
|
||||
// First scan
|
||||
assert!(filter.should_process(Pose2D::new(0.0, 0.0, 0.0)));
|
||||
|
||||
// Small movement (< 0.2m) - filtered
|
||||
assert!(!filter.should_process(Pose2D::new(0.1, 0.1, 0.0)));
|
||||
|
||||
// Large movement (> 0.2m) - passes
|
||||
assert!(filter.should_process(Pose2D::new(0.5, 0.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_angle_threshold() {
|
||||
let mut filter = MotionFilter::new(MotionFilterConfig::default());
|
||||
|
||||
// First scan
|
||||
assert!(filter.should_process(Pose2D::new(0.0, 0.0, 0.0)));
|
||||
|
||||
// Small rotation (< 0.17 rad) - filtered
|
||||
assert!(!filter.should_process(Pose2D::new(0.0, 0.0, 0.1)));
|
||||
|
||||
// Large rotation (> 0.17 rad) - passes
|
||||
assert!(filter.should_process(Pose2D::new(0.0, 0.0, 0.5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_time_threshold() {
|
||||
let config = MotionFilterConfig {
|
||||
max_time_secs: 0.05, // 50ms for fast test
|
||||
..Default::default()
|
||||
};
|
||||
let mut filter = MotionFilter::new(config);
|
||||
|
||||
// First scan
|
||||
assert!(filter.should_process(Pose2D::new(0.0, 0.0, 0.0)));
|
||||
|
||||
// Same pose immediately - filtered
|
||||
assert!(!filter.should_process(Pose2D::new(0.0, 0.0, 0.0)));
|
||||
|
||||
// Wait for time threshold
|
||||
sleep(Duration::from_millis(60));
|
||||
|
||||
// Same pose after timeout - passes
|
||||
assert!(filter.should_process(Pose2D::new(0.0, 0.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disabled_filter() {
|
||||
let mut filter = MotionFilter::new(MotionFilterConfig::disabled());
|
||||
let pose = Pose2D::new(0.0, 0.0, 0.0);
|
||||
|
||||
// All scans should pass when disabled
|
||||
assert!(filter.should_process(pose));
|
||||
assert!(filter.should_process(pose));
|
||||
assert!(filter.should_process(pose));
|
||||
|
||||
assert_eq!(filter.passed_count(), 3);
|
||||
assert_eq!(filter.filtered_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset() {
|
||||
let mut filter = MotionFilter::new(MotionFilterConfig::default());
|
||||
|
||||
filter.should_process(Pose2D::new(0.0, 0.0, 0.0));
|
||||
filter.should_process(Pose2D::new(0.0, 0.0, 0.0));
|
||||
|
||||
filter.reset();
|
||||
|
||||
assert_eq!(filter.passed_count(), 0);
|
||||
assert_eq!(filter.filtered_count(), 0);
|
||||
|
||||
// After reset, first scan passes again
|
||||
assert!(filter.should_process(Pose2D::new(0.0, 0.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_efficiency() {
|
||||
let mut filter = MotionFilter::new(MotionFilterConfig::default());
|
||||
|
||||
filter.should_process(Pose2D::new(0.0, 0.0, 0.0)); // pass
|
||||
filter.should_process(Pose2D::new(0.0, 0.0, 0.0)); // filter
|
||||
filter.should_process(Pose2D::new(0.0, 0.0, 0.0)); // filter
|
||||
filter.should_process(Pose2D::new(1.0, 0.0, 0.0)); // pass (distance)
|
||||
|
||||
assert_eq!(filter.passed_count(), 2);
|
||||
assert_eq!(filter.filtered_count(), 2);
|
||||
assert!((filter.filter_efficiency() - 0.5).abs() < 0.001);
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,17 @@
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::controller::{
|
||||
ExplorationCommand, ExplorationController, ExplorationEvent, ExplorationProgress,
|
||||
};
|
||||
use super::error::ExplorationError;
|
||||
use super::motion_filter::MotionFilter;
|
||||
use super::velocity::{
|
||||
VelocityConfig, compute_angular_velocity_to_heading, compute_velocity_to_target,
|
||||
};
|
||||
use crate::core::{LidarScan, Pose2D};
|
||||
use crate::exploration::{ExplorationCommand, ExplorationController, ExplorationProgress};
|
||||
use crate::grid::GridStorage;
|
||||
use crate::slam::CorrelativeMatcherConfig;
|
||||
use crate::submap::{
|
||||
@@ -17,32 +26,45 @@ use crate::submap::{
|
||||
};
|
||||
|
||||
use super::config::ExplorerConfig;
|
||||
use super::explorer::{ExploreResult, ExploreStatus, normalize_angle};
|
||||
use super::explorer::{ExploreResult, ExploreStatus};
|
||||
use super::source::SensorSource;
|
||||
|
||||
/// Configuration specific to submap-based exploration.
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SubmapExplorerConfig {
|
||||
/// Base explorer configuration.
|
||||
#[serde(default)]
|
||||
pub base: ExplorerConfig,
|
||||
/// Submap configuration.
|
||||
#[serde(default)]
|
||||
pub submap: SubmapConfig,
|
||||
/// Multi-submap matching configuration.
|
||||
#[serde(default)]
|
||||
pub multi_match: MultiSubmapMatchConfig,
|
||||
/// Minimum score to accept a loop closure.
|
||||
#[serde(default = "default_loop_closure_min_score")]
|
||||
pub loop_closure_min_score: f32,
|
||||
/// Minimum submap gap for loop closure (submaps between).
|
||||
#[serde(default = "default_loop_closure_min_gap")]
|
||||
pub loop_closure_min_gap: usize,
|
||||
}
|
||||
|
||||
fn default_loop_closure_min_score() -> f32 {
|
||||
0.7
|
||||
}
|
||||
|
||||
fn default_loop_closure_min_gap() -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
impl Default for SubmapExplorerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base: ExplorerConfig::default(),
|
||||
submap: SubmapConfig::default(),
|
||||
multi_match: MultiSubmapMatchConfig::default(),
|
||||
loop_closure_min_score: 0.7,
|
||||
loop_closure_min_gap: 3,
|
||||
loop_closure_min_score: default_loop_closure_min_score(),
|
||||
loop_closure_min_gap: default_loop_closure_min_gap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +107,10 @@ pub struct VastuSubmapExplorer {
|
||||
controller: ExplorationController,
|
||||
/// Matcher configuration.
|
||||
matcher_config: CorrelativeMatcherConfig,
|
||||
/// Motion filter for gating scan processing.
|
||||
motion_filter: MotionFilter,
|
||||
/// Velocity computation configuration.
|
||||
velocity_config: VelocityConfig,
|
||||
/// Last corrected world pose.
|
||||
last_pose: Pose2D,
|
||||
/// Last odometry pose (for delta calculation).
|
||||
@@ -109,6 +135,8 @@ impl VastuSubmapExplorer {
|
||||
let pose_graph = SubmapPoseGraph::new(Default::default());
|
||||
let controller = ExplorationController::new(config.base.exploration.clone());
|
||||
let matcher_config = config.base.matching.clone();
|
||||
let motion_filter = MotionFilter::new(config.base.motion_filter.clone());
|
||||
let velocity_config = VelocityConfig::default();
|
||||
|
||||
Self {
|
||||
config,
|
||||
@@ -116,6 +144,8 @@ impl VastuSubmapExplorer {
|
||||
pose_graph,
|
||||
controller,
|
||||
matcher_config,
|
||||
motion_filter,
|
||||
velocity_config,
|
||||
last_pose: Pose2D::default(),
|
||||
last_odom_pose: None,
|
||||
last_submap_origin: None,
|
||||
@@ -191,8 +221,8 @@ impl VastuSubmapExplorer {
|
||||
if !source.is_connected() {
|
||||
source.stop();
|
||||
return ExploreResult::Failed {
|
||||
map: self.to_occupancy_grid_map(),
|
||||
reason: "Robot connection lost".to_string(),
|
||||
map: self.build_occupancy_grid_map(),
|
||||
error: ExplorationError::ConnectionLost,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -202,8 +232,11 @@ impl VastuSubmapExplorer {
|
||||
{
|
||||
source.stop();
|
||||
return ExploreResult::Failed {
|
||||
map: self.to_occupancy_grid_map(),
|
||||
reason: format!("Battery low: {}%", battery),
|
||||
map: self.build_occupancy_grid_map(),
|
||||
error: ExplorationError::LowBattery {
|
||||
percent: battery,
|
||||
threshold: self.config.base.min_battery_percent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -213,8 +246,11 @@ impl VastuSubmapExplorer {
|
||||
{
|
||||
source.stop();
|
||||
return ExploreResult::Failed {
|
||||
map: self.to_occupancy_grid_map(),
|
||||
reason: "Time limit exceeded".to_string(),
|
||||
map: self.build_occupancy_grid_map(),
|
||||
error: ExplorationError::TimeoutExceeded {
|
||||
elapsed_secs: self.elapsed_secs(),
|
||||
limit_secs: self.config.base.max_time_secs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -225,13 +261,13 @@ impl VastuSubmapExplorer {
|
||||
}
|
||||
ExploreStatus::Complete => {
|
||||
source.stop();
|
||||
return ExploreResult::Complete(self.to_occupancy_grid_map());
|
||||
return ExploreResult::Complete(self.build_occupancy_grid_map());
|
||||
}
|
||||
ExploreStatus::Failed { reason } => {
|
||||
ExploreStatus::Failed { error } => {
|
||||
source.stop();
|
||||
return ExploreResult::Failed {
|
||||
map: self.to_occupancy_grid_map(),
|
||||
reason,
|
||||
map: self.build_occupancy_grid_map(),
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -269,10 +305,19 @@ impl VastuSubmapExplorer {
|
||||
});
|
||||
self.last_odom_pose = Some(odom_pose);
|
||||
|
||||
// Process lidar scan if available
|
||||
// Process lidar scan if available and motion filter allows
|
||||
if let Some(scan) = source.get_lidar_scan() {
|
||||
let corrected_pose = self.process_scan(&scan, odom_pose);
|
||||
self.last_pose = corrected_pose;
|
||||
if self.motion_filter.should_process(odom_pose) {
|
||||
let corrected_pose = self.process_scan(&scan, odom_pose);
|
||||
self.last_pose = corrected_pose;
|
||||
} else if let Some(delta) = odom_delta {
|
||||
// Scan filtered out - update pose from odometry delta only
|
||||
self.last_pose = Pose2D::new(
|
||||
self.last_pose.x + delta.x,
|
||||
self.last_pose.y + delta.y,
|
||||
self.last_pose.theta + delta.theta,
|
||||
);
|
||||
}
|
||||
} else if let Some(delta) = odom_delta {
|
||||
// No scan - update from odometry delta
|
||||
self.last_pose = Pose2D::new(
|
||||
@@ -285,7 +330,7 @@ impl VastuSubmapExplorer {
|
||||
// Handle cliff/bumper events
|
||||
if source.is_cliff_detected() || source.is_bumper_triggered() {
|
||||
self.controller
|
||||
.handle_event(crate::exploration::ExplorationEvent::ObstacleDetected);
|
||||
.handle_event(ExplorationEvent::ObstacleDetected);
|
||||
}
|
||||
|
||||
// Get current global grid for planning
|
||||
@@ -297,18 +342,23 @@ impl VastuSubmapExplorer {
|
||||
// Execute command
|
||||
match command {
|
||||
ExplorationCommand::MoveTo { target, max_speed } => {
|
||||
let (linear, angular) =
|
||||
self.compute_velocity(self.last_pose, target.x, target.y, max_speed);
|
||||
let (linear, angular) = compute_velocity_to_target(
|
||||
self.last_pose,
|
||||
target,
|
||||
max_speed,
|
||||
&self.velocity_config,
|
||||
);
|
||||
source.send_velocity(linear, angular);
|
||||
}
|
||||
ExplorationCommand::Rotate {
|
||||
target_heading,
|
||||
max_angular_speed,
|
||||
} => {
|
||||
let angular = self.compute_angular_velocity(
|
||||
let angular = compute_angular_velocity_to_heading(
|
||||
self.last_pose.theta,
|
||||
target_heading,
|
||||
max_angular_speed,
|
||||
&self.velocity_config,
|
||||
);
|
||||
source.send_velocity(0.0, angular);
|
||||
}
|
||||
@@ -321,7 +371,9 @@ impl VastuSubmapExplorer {
|
||||
}
|
||||
ExplorationCommand::ExplorationFailed { reason } => {
|
||||
source.stop();
|
||||
return ExploreStatus::Failed { reason };
|
||||
return ExploreStatus::Failed {
|
||||
error: ExplorationError::from_message(reason),
|
||||
};
|
||||
}
|
||||
ExplorationCommand::None => {
|
||||
// Continue with current velocity or stop
|
||||
@@ -450,7 +502,7 @@ impl VastuSubmapExplorer {
|
||||
}
|
||||
|
||||
/// Convert to OccupancyGridMap for compatibility with ExploreResult.
|
||||
fn to_occupancy_grid_map(&mut self) -> crate::OccupancyGridMap {
|
||||
fn build_occupancy_grid_map(&mut self) -> crate::OccupancyGridMap {
|
||||
let map_config = self.config.base.to_map_config();
|
||||
let mut map = crate::OccupancyGridMap::new(map_config);
|
||||
|
||||
@@ -470,54 +522,13 @@ impl VastuSubmapExplorer {
|
||||
map
|
||||
}
|
||||
|
||||
/// Compute velocity command to move towards target.
|
||||
fn compute_velocity(
|
||||
&self,
|
||||
pose: Pose2D,
|
||||
target_x: f32,
|
||||
target_y: f32,
|
||||
max_speed: f32,
|
||||
) -> (f32, f32) {
|
||||
let dx = target_x - pose.x;
|
||||
let dy = target_y - pose.y;
|
||||
let distance = (dx * dx + dy * dy).sqrt();
|
||||
|
||||
if distance < 0.05 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
|
||||
let target_angle = dy.atan2(dx);
|
||||
let angle_error = normalize_angle(target_angle - pose.theta);
|
||||
|
||||
// Turn towards target first if angle is large
|
||||
if angle_error.abs() > 0.3 {
|
||||
let angular = angle_error.signum() * 2.0f32.min(angle_error.abs() * 2.0);
|
||||
return (0.0, angular);
|
||||
}
|
||||
|
||||
// Move forward with proportional angular correction
|
||||
let linear = max_speed.min(distance);
|
||||
let angular = (angle_error * 2.0).clamp(-2.0, 2.0);
|
||||
|
||||
(linear, angular)
|
||||
}
|
||||
|
||||
/// Compute angular velocity to rotate to target heading.
|
||||
fn compute_angular_velocity(&self, current: f32, target: f32, max_speed: f32) -> f32 {
|
||||
let error = normalize_angle(target - current);
|
||||
if error.abs() < 0.05 {
|
||||
0.0
|
||||
} else {
|
||||
(error * 2.0).clamp(-max_speed, max_speed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset explorer for a new exploration run.
|
||||
pub fn reset(&mut self) {
|
||||
let map_config = self.config.base.to_map_config();
|
||||
self.manager = SubmapManager::new(self.config.submap.clone(), &map_config);
|
||||
self.pose_graph = SubmapPoseGraph::new(Default::default());
|
||||
self.controller = ExplorationController::new(self.config.base.exploration.clone());
|
||||
self.motion_filter.reset();
|
||||
self.last_pose = Pose2D::default();
|
||||
self.last_odom_pose = None;
|
||||
self.last_submap_origin = None;
|
||||
@@ -526,6 +537,17 @@ impl VastuSubmapExplorer {
|
||||
self.start_time = None;
|
||||
self.started = false;
|
||||
}
|
||||
|
||||
/// Get motion filter statistics.
|
||||
///
|
||||
/// Returns (passed_count, filtered_count, efficiency).
|
||||
pub fn motion_filter_stats(&self) -> (u64, u64, f32) {
|
||||
(
|
||||
self.motion_filter.passed_count(),
|
||||
self.motion_filter.filtered_count(),
|
||||
self.motion_filter.filter_efficiency(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Velocity computation utilities for exploration.
|
||||
//!
|
||||
//! Shared functions for computing linear and angular velocities
|
||||
//! to navigate towards target positions.
|
||||
|
||||
use crate::core::{Pose2D, WorldPoint, normalize_angle};
|
||||
|
||||
/// Configuration for velocity computation.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VelocityConfig {
|
||||
/// Angle threshold (radians) - turn in place if angle error exceeds this.
|
||||
/// Default: 0.3 rad (~17 degrees)
|
||||
pub turn_threshold: f32,
|
||||
|
||||
/// Proportional gain for angular velocity.
|
||||
/// Default: 2.0
|
||||
pub angular_gain: f32,
|
||||
|
||||
/// Maximum angular velocity (rad/s).
|
||||
/// Default: 2.0
|
||||
pub max_angular_velocity: f32,
|
||||
|
||||
/// Distance threshold to consider target reached (meters).
|
||||
/// Default: 0.05m (5cm)
|
||||
pub reached_threshold: f32,
|
||||
|
||||
/// Angle threshold to consider heading reached (radians).
|
||||
/// Default: 0.05 rad (~3 degrees)
|
||||
pub heading_reached_threshold: f32,
|
||||
}
|
||||
|
||||
impl Default for VelocityConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
turn_threshold: 0.3,
|
||||
angular_gain: 2.0,
|
||||
max_angular_velocity: 2.0,
|
||||
reached_threshold: 0.05,
|
||||
heading_reached_threshold: 0.05,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute velocity command to move towards a target position.
|
||||
///
|
||||
/// Returns (linear_velocity, angular_velocity) in (m/s, rad/s).
|
||||
///
|
||||
/// The behavior is:
|
||||
/// 1. If angle to target exceeds `turn_threshold`, rotate in place first
|
||||
/// 2. Otherwise, move forward with proportional angular correction
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `pose` - Current robot pose
|
||||
/// * `target` - Target position in world coordinates
|
||||
/// * `max_speed` - Maximum linear speed (m/s)
|
||||
/// * `config` - Velocity computation configuration
|
||||
pub fn compute_velocity_to_target(
|
||||
pose: Pose2D,
|
||||
target: WorldPoint,
|
||||
max_speed: f32,
|
||||
config: &VelocityConfig,
|
||||
) -> (f32, f32) {
|
||||
let dx = target.x - pose.x;
|
||||
let dy = target.y - pose.y;
|
||||
let distance = (dx * dx + dy * dy).sqrt();
|
||||
|
||||
// Target reached
|
||||
if distance < config.reached_threshold {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
|
||||
let target_angle = dy.atan2(dx);
|
||||
let angle_error = normalize_angle(target_angle - pose.theta);
|
||||
|
||||
// Turn towards target first if angle is large
|
||||
if angle_error.abs() > config.turn_threshold {
|
||||
let angular = angle_error.signum()
|
||||
* config
|
||||
.max_angular_velocity
|
||||
.min(angle_error.abs() * config.angular_gain);
|
||||
return (0.0, angular);
|
||||
}
|
||||
|
||||
// Move forward with proportional angular correction
|
||||
let linear = max_speed.min(distance);
|
||||
let angular = (angle_error * config.angular_gain)
|
||||
.clamp(-config.max_angular_velocity, config.max_angular_velocity);
|
||||
|
||||
(linear, angular)
|
||||
}
|
||||
|
||||
/// Compute angular velocity to rotate to a target heading.
|
||||
///
|
||||
/// Returns angular velocity in rad/s.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `current_heading` - Current heading (radians)
|
||||
/// * `target_heading` - Target heading (radians)
|
||||
/// * `max_speed` - Maximum angular speed (rad/s)
|
||||
/// * `config` - Velocity computation configuration
|
||||
pub fn compute_angular_velocity_to_heading(
|
||||
current_heading: f32,
|
||||
target_heading: f32,
|
||||
max_speed: f32,
|
||||
config: &VelocityConfig,
|
||||
) -> f32 {
|
||||
let error = normalize_angle(target_heading - current_heading);
|
||||
|
||||
if error.abs() < config.heading_reached_threshold {
|
||||
0.0
|
||||
} else {
|
||||
(error * config.angular_gain).clamp(-max_speed, max_speed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
#[test]
|
||||
fn test_target_reached() {
|
||||
let config = VelocityConfig::default();
|
||||
let pose = Pose2D::new(1.0, 1.0, 0.0);
|
||||
let target = WorldPoint::new(1.02, 1.02); // Within 5cm
|
||||
|
||||
let (linear, angular) = compute_velocity_to_target(pose, target, 0.5, &config);
|
||||
assert_eq!(linear, 0.0);
|
||||
assert_eq!(angular, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_turn_first_when_angle_large() {
|
||||
let config = VelocityConfig::default();
|
||||
let pose = Pose2D::new(0.0, 0.0, 0.0); // Facing +X
|
||||
let target = WorldPoint::new(0.0, 1.0); // Target is +Y (90 degrees away)
|
||||
|
||||
let (linear, angular) = compute_velocity_to_target(pose, target, 0.5, &config);
|
||||
assert_eq!(linear, 0.0); // Should not move forward
|
||||
assert!(angular > 0.0); // Should turn left (positive)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_forward_when_aligned() {
|
||||
let config = VelocityConfig::default();
|
||||
let pose = Pose2D::new(0.0, 0.0, 0.0); // Facing +X
|
||||
let target = WorldPoint::new(1.0, 0.0); // Target straight ahead
|
||||
|
||||
let (linear, angular) = compute_velocity_to_target(pose, target, 0.5, &config);
|
||||
assert!(linear > 0.0); // Should move forward
|
||||
assert!(angular.abs() < 0.01); // Minimal angular correction
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heading_reached() {
|
||||
let config = VelocityConfig::default();
|
||||
let angular = compute_angular_velocity_to_heading(0.0, 0.02, 1.0, &config);
|
||||
assert_eq!(angular, 0.0); // Within threshold
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rotate_to_heading() {
|
||||
let config = VelocityConfig::default();
|
||||
let angular = compute_angular_velocity_to_heading(0.0, PI / 2.0, 1.0, &config);
|
||||
assert!(angular > 0.0); // Should turn positive (counter-clockwise)
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,128 @@ impl Default for SensorConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for log-odds occupancy updates.
|
||||
///
|
||||
/// Based on Google Cartographer's probability grid model:
|
||||
/// - Log-odds: L(x) = log(P(x) / (1 - P(x)))
|
||||
/// - Bayesian update: L_new = L_old + L_observation
|
||||
/// - Stored as fixed-point i16: actual = value / 100
|
||||
///
|
||||
/// Lower values = more observations needed to establish walls.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct LogOddsConfig {
|
||||
/// Log-odds increment for hit (lidar endpoint = occupied).
|
||||
/// Cartographer default: ~20 (hit_probability=0.55)
|
||||
/// VastuMap aggressive: 70 (P=0.67)
|
||||
#[serde(default = "default_l_hit")]
|
||||
pub l_hit: i16,
|
||||
|
||||
/// Log-odds decrement for miss (ray passes through = free).
|
||||
/// Cartographer default: ~-4 (miss_probability=0.49)
|
||||
/// VastuMap aggressive: -28 (P=0.43)
|
||||
/// Asymmetric to make walls "stickier" than free space.
|
||||
#[serde(default = "default_l_miss")]
|
||||
pub l_miss: i16,
|
||||
|
||||
/// Threshold for considering cell occupied. P > 0.62 → L > 50
|
||||
#[serde(default = "default_l_occupied")]
|
||||
pub l_occupied_threshold: i16,
|
||||
|
||||
/// Threshold for considering cell free. P < 0.38 → L < -50
|
||||
#[serde(default = "default_l_free")]
|
||||
pub l_free_threshold: i16,
|
||||
|
||||
/// Minimum log-odds value (clamping). P ≈ 0.12
|
||||
#[serde(default = "default_l_min")]
|
||||
pub l_min: i16,
|
||||
|
||||
/// Maximum log-odds value (clamping). P ≈ 0.88
|
||||
#[serde(default = "default_l_max")]
|
||||
pub l_max: i16,
|
||||
}
|
||||
|
||||
fn default_l_hit() -> i16 {
|
||||
20
|
||||
} // P≈0.55 (Cartographer default)
|
||||
fn default_l_miss() -> i16 {
|
||||
-4
|
||||
} // P≈0.49 (Cartographer default)
|
||||
fn default_l_occupied() -> i16 {
|
||||
50
|
||||
}
|
||||
fn default_l_free() -> i16 {
|
||||
-50
|
||||
}
|
||||
fn default_l_min() -> i16 {
|
||||
-200
|
||||
}
|
||||
fn default_l_max() -> i16 {
|
||||
200
|
||||
}
|
||||
|
||||
impl Default for LogOddsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
l_hit: default_l_hit(),
|
||||
l_miss: default_l_miss(),
|
||||
l_occupied_threshold: default_l_occupied(),
|
||||
l_free_threshold: default_l_free(),
|
||||
l_min: default_l_min(),
|
||||
l_max: default_l_max(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LogOddsConfig {
|
||||
/// Cartographer-like conservative updates (DEFAULT).
|
||||
/// Requires 3-5 observations to establish wall.
|
||||
/// This is the recommended default for robust mapping.
|
||||
pub fn cartographer() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Aggressive updates (original VastuMap behavior).
|
||||
/// Single observation can establish wall.
|
||||
/// Use for fast mapping in controlled environments.
|
||||
pub fn aggressive() -> Self {
|
||||
Self {
|
||||
l_hit: 70, // P=0.67
|
||||
l_miss: -28, // P=0.43
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Balanced - 2-3 observations for wall.
|
||||
/// Mid-ground between Cartographer and aggressive.
|
||||
pub fn balanced() -> Self {
|
||||
Self {
|
||||
l_hit: 30, // P=0.57
|
||||
l_miss: -12, // P=0.47
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert hit probability to log-odds.
|
||||
/// Formula: L = 100 * log(p / (1 - p))
|
||||
pub fn from_probability(hit_prob: f32, miss_prob: f32) -> Self {
|
||||
let l_hit = (100.0 * (hit_prob / (1.0 - hit_prob)).ln()) as i16;
|
||||
let l_miss = (100.0 * (miss_prob / (1.0 - miss_prob)).ln()) as i16;
|
||||
Self {
|
||||
l_hit,
|
||||
l_miss,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert log-odds to probability.
|
||||
/// Formula: P = exp(L/100) / (1 + exp(L/100))
|
||||
pub fn log_odds_to_probability(log_odds: i16) -> f32 {
|
||||
let l = log_odds as f32 / 100.0;
|
||||
let exp_l = l.exp();
|
||||
exp_l / (1.0 + exp_l)
|
||||
}
|
||||
}
|
||||
|
||||
/// Full map configuration
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct MapConfig {
|
||||
@@ -122,6 +244,9 @@ pub struct MapConfig {
|
||||
pub grid: GridConfig,
|
||||
/// Sensor configuration (lidar, robot geometry)
|
||||
pub sensor: SensorConfig,
|
||||
/// Log-odds occupancy configuration
|
||||
#[serde(default)]
|
||||
pub log_odds: LogOddsConfig,
|
||||
}
|
||||
|
||||
impl MapConfig {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
//! Lidar sensor update for occupancy grid.
|
||||
//!
|
||||
//! Processes lidar scans to update the grid:
|
||||
//! - Cells along rays are marked as Floor (free space)
|
||||
//! - Cells at ray endpoints (within range) are marked as Wall (obstacle)
|
||||
//! Processes lidar scans to update the grid using log-odds Bayesian updates:
|
||||
//! - Cells along rays receive "miss" observations (evidence of free space)
|
||||
//! - Cells at ray endpoints (within range) receive "hit" observations (evidence of obstacle)
|
||||
//!
|
||||
//! Log-odds representation provides:
|
||||
//! - Proper Bayesian update: L_new = L_old + L_observation
|
||||
//! - Better uncertainty handling for conflicting observations
|
||||
//! - Thinner wall representation (2-3cm vs 5-8cm)
|
||||
|
||||
use crate::ObserveResult;
|
||||
use crate::core::{CellType, LidarScan, Pose2D, WorldPoint};
|
||||
use crate::core::{LidarScan, Pose2D, WorldPoint};
|
||||
use crate::grid::config::{GridConfig, SensorConfig};
|
||||
use crate::grid::raycaster::BresenhamLine;
|
||||
use crate::grid::storage::GridStorage;
|
||||
@@ -81,7 +86,8 @@ pub fn update_from_lidar(
|
||||
}
|
||||
}
|
||||
|
||||
// Mark cells along the ray as Floor (iterate directly, no allocation)
|
||||
// Trace ray using Bresenham line algorithm
|
||||
// Apply log-odds updates: miss for ray cells, hit for endpoint
|
||||
let bresenham = BresenhamLine::new(sensor_coord, endpoint_coord);
|
||||
|
||||
// Use peekable to detect the last element without collecting
|
||||
@@ -94,15 +100,17 @@ pub fn update_from_lidar(
|
||||
let is_last = iter.peek().is_none();
|
||||
|
||||
if !is_last {
|
||||
// Not the endpoint - mark as Floor
|
||||
if storage.set_type(coord, CellType::Floor) {
|
||||
// Not the endpoint - apply "miss" observation (evidence of free space)
|
||||
// This uses Bayesian log-odds update: L_new = L_old + L_MISS
|
||||
if storage.apply_miss(coord) {
|
||||
result.cells_floor += 1;
|
||||
}
|
||||
result.cells_updated += 1;
|
||||
} else {
|
||||
// Endpoint - mark as Wall if within max range
|
||||
// Endpoint - apply "hit" observation if within max range
|
||||
// This uses Bayesian log-odds update: L_new = L_old + L_HIT
|
||||
if range < sensor_config.max_lidar_range {
|
||||
if storage.set_type_with_priority(coord, CellType::Wall) {
|
||||
if storage.apply_hit(coord) {
|
||||
result.cells_wall += 1;
|
||||
new_wall_coords.push(coord);
|
||||
}
|
||||
@@ -120,9 +128,15 @@ pub fn update_from_lidar(
|
||||
result
|
||||
}
|
||||
|
||||
/// Update the grid with a single lidar ray.
|
||||
/// Update the grid with a single lidar ray using log-odds updates.
|
||||
///
|
||||
/// This is a lower-level function useful for incremental updates.
|
||||
/// Uses Bayesian log-odds updates for proper uncertainty handling.
|
||||
///
|
||||
/// Returns (cells_updated, hit_registered) where hit_registered is true
|
||||
/// if a hit observation was applied to the endpoint (range < max_range).
|
||||
/// Note: hit_registered means a hit observation was recorded, not that
|
||||
/// the cell is classified as occupied (which requires accumulated observations).
|
||||
pub fn update_single_ray(
|
||||
storage: &mut GridStorage,
|
||||
sensor_pos: WorldPoint,
|
||||
@@ -144,9 +158,9 @@ pub fn update_single_ray(
|
||||
}
|
||||
|
||||
let mut cells_updated = 0;
|
||||
let mut hit_wall = false;
|
||||
let mut hit_registered = false;
|
||||
|
||||
// Trace ray (iterate directly, no allocation)
|
||||
// Trace ray using Bresenham line algorithm
|
||||
let bresenham = BresenhamLine::new(sensor_coord, endpoint_coord);
|
||||
let mut iter = bresenham.peekable();
|
||||
|
||||
@@ -158,48 +172,78 @@ pub fn update_single_ray(
|
||||
let is_last = iter.peek().is_none();
|
||||
|
||||
if !is_last {
|
||||
storage.set_type(coord, CellType::Floor);
|
||||
// Apply miss observation (evidence of free space)
|
||||
storage.apply_miss(coord);
|
||||
} else if range < max_range {
|
||||
storage.set_type_with_priority(coord, CellType::Wall);
|
||||
hit_wall = true;
|
||||
// Apply hit observation (evidence of obstacle)
|
||||
// Returns whether cell became occupied, but we just care that we registered a hit
|
||||
let _ = storage.apply_hit(coord);
|
||||
hit_registered = true;
|
||||
}
|
||||
cells_updated += 1;
|
||||
}
|
||||
|
||||
(cells_updated, hit_wall)
|
||||
(cells_updated, hit_registered)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::CellType;
|
||||
use crate::grid::LogOddsConfig;
|
||||
|
||||
fn create_test_storage() -> GridStorage {
|
||||
GridStorage::centered(100, 100, 0.05) // 5m x 5m at 5cm resolution
|
||||
}
|
||||
|
||||
fn create_aggressive_storage() -> GridStorage {
|
||||
// Use aggressive config where single hit crosses threshold
|
||||
GridStorage::centered_with_config(100, 100, 0.05, LogOddsConfig::aggressive())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_ray_update() {
|
||||
let mut storage = create_test_storage();
|
||||
// Use aggressive config so fewer observations are needed
|
||||
let mut storage = create_aggressive_storage();
|
||||
let sensor_pos = WorldPoint::new(0.0, 0.0);
|
||||
|
||||
// Cast a ray 1m forward
|
||||
let (cells, hit) = update_single_ray(&mut storage, sensor_pos, 0.0, 1.0, 8.0);
|
||||
// Cast a ray 1m forward multiple times to accumulate log-odds
|
||||
// With aggressive config (l_miss=-28), 2 misses needed for floor: 0-28-28=-56 < -50
|
||||
// With aggressive config (l_hit=70), 1 hit needed for wall: 0+70=70 > 50
|
||||
for _ in 0..3 {
|
||||
let (cells, _hit) = update_single_ray(&mut storage, sensor_pos, 0.0, 1.0, 8.0);
|
||||
assert!(cells > 0);
|
||||
}
|
||||
|
||||
assert!(cells > 0);
|
||||
assert!(hit);
|
||||
|
||||
// Check that cells along the ray are Floor
|
||||
// Check that cells along the ray have negative log-odds (free space evidence)
|
||||
let mid_coord = storage.world_to_grid(WorldPoint::new(0.5, 0.0));
|
||||
let mid_log_odds = storage.get_log_odds(mid_coord);
|
||||
assert!(
|
||||
mid_log_odds < 0,
|
||||
"Expected negative log-odds for ray cells, got {}",
|
||||
mid_log_odds
|
||||
);
|
||||
|
||||
// After enough observations, should be marked as Floor
|
||||
assert_eq!(storage.get_type(mid_coord), CellType::Floor);
|
||||
|
||||
// Check that endpoint is Wall
|
||||
// Check that endpoint has positive log-odds (obstacle evidence)
|
||||
let end_coord = storage.world_to_grid(WorldPoint::new(1.0, 0.0));
|
||||
let end_log_odds = storage.get_log_odds(end_coord);
|
||||
assert!(
|
||||
end_log_odds > 0,
|
||||
"Expected positive log-odds for endpoint, got {}",
|
||||
end_log_odds
|
||||
);
|
||||
|
||||
// After enough observations, should be marked as Wall
|
||||
assert_eq!(storage.get_type(end_coord), CellType::Wall);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lidar_scan_update() {
|
||||
let mut storage = create_test_storage();
|
||||
// Use aggressive config so single hits cross threshold
|
||||
let mut storage = create_aggressive_storage();
|
||||
let sensor_config = SensorConfig::default();
|
||||
let grid_config = GridConfig::default();
|
||||
let robot_pose = Pose2D::new(0.0, 0.0, 0.0);
|
||||
@@ -226,7 +270,10 @@ mod tests {
|
||||
);
|
||||
|
||||
assert!(result.cells_updated > 0);
|
||||
assert!(result.cells_wall > 0);
|
||||
assert!(
|
||||
result.cells_wall > 0,
|
||||
"With aggressive config, single hit should cross threshold"
|
||||
);
|
||||
assert!(result.cells_floor > 0);
|
||||
|
||||
// Check walls in 4 directions
|
||||
@@ -270,15 +317,34 @@ mod tests {
|
||||
let (cells, hit) = update_single_ray(&mut storage, sensor_pos, angle, 1.0, 8.0);
|
||||
|
||||
assert!(cells > 0);
|
||||
assert!(hit);
|
||||
assert!(hit, "Should register a hit observation");
|
||||
|
||||
// Check diagonal endpoint
|
||||
// Check diagonal endpoint has positive log-odds (evidence of obstacle)
|
||||
let dist = 1.0 / std::f32::consts::SQRT_2;
|
||||
let end_coord = storage.world_to_grid(WorldPoint::new(dist, dist));
|
||||
// The actual endpoint might be slightly different due to resolution
|
||||
let log_odds = storage.get_log_odds(end_coord);
|
||||
assert!(
|
||||
storage.get_type(end_coord) == CellType::Wall
|
||||
|| storage.get_type(end_coord) == CellType::Floor
|
||||
log_odds > 0,
|
||||
"Endpoint should have positive log-odds after hit, got {}",
|
||||
log_odds
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diagonal_ray_aggressive() {
|
||||
// Use aggressive config for immediate wall classification
|
||||
let mut storage = create_aggressive_storage();
|
||||
let sensor_pos = WorldPoint::new(0.0, 0.0);
|
||||
|
||||
let angle = std::f32::consts::FRAC_PI_4; // 45 degrees
|
||||
let (cells, hit) = update_single_ray(&mut storage, sensor_pos, angle, 1.0, 8.0);
|
||||
|
||||
assert!(cells > 0);
|
||||
assert!(hit);
|
||||
|
||||
// With aggressive config, single hit should classify as Wall
|
||||
let dist = 1.0 / std::f32::consts::SQRT_2;
|
||||
let end_coord = storage.world_to_grid(WorldPoint::new(dist, dist));
|
||||
assert_eq!(storage.get_type(end_coord), CellType::Wall);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,5 @@ pub mod lidar_update;
|
||||
pub mod raycaster;
|
||||
mod storage;
|
||||
|
||||
pub use config::{ConfigError, GridConfig, MapConfig, SensorConfig};
|
||||
pub use storage::{CellCounts, GridStorage};
|
||||
pub use config::{ConfigError, GridConfig, LogOddsConfig, MapConfig, SensorConfig};
|
||||
pub use storage::{CellCounts, CellMut, GridStorage};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,268 @@
|
||||
//! Grid storage implementation for the occupancy grid.
|
||||
//!
|
||||
//! Uses Structure-of-Arrays (SoA) layout for SIMD optimization.
|
||||
//! Cell data is stored in separate arrays for each field, enabling
|
||||
//! efficient vectorized operations on ARM NEON and x86 SSE/AVX.
|
||||
|
||||
mod core;
|
||||
mod types;
|
||||
|
||||
pub use self::core::GridStorage;
|
||||
pub use types::{CellCounts, CellMut};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::{CellType, GridCoord, WorldPoint};
|
||||
use crate::grid::LogOddsConfig;
|
||||
|
||||
#[test]
|
||||
fn test_grid_creation() {
|
||||
let grid = GridStorage::new(100, 100, 0.05, WorldPoint::ZERO);
|
||||
assert_eq!(grid.width(), 100);
|
||||
assert_eq!(grid.height(), 100);
|
||||
assert_eq!(grid.resolution(), 0.05);
|
||||
assert_eq!(grid.cell_count(), 10000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_centered_grid() {
|
||||
let grid = GridStorage::centered(100, 100, 0.05);
|
||||
let (min, max) = grid.bounds();
|
||||
|
||||
assert!((min.x + 2.5).abs() < 1e-6);
|
||||
assert!((min.y + 2.5).abs() < 1e-6);
|
||||
assert!((max.x - 2.5).abs() < 1e-6);
|
||||
assert!((max.y - 2.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_world_to_grid_conversion() {
|
||||
let grid = GridStorage::new(100, 100, 0.05, WorldPoint::ZERO);
|
||||
|
||||
let coord = grid.world_to_grid(WorldPoint::new(0.0, 0.0));
|
||||
assert_eq!(coord, GridCoord::new(0, 0));
|
||||
|
||||
let coord = grid.world_to_grid(WorldPoint::new(1.0, 1.0));
|
||||
assert_eq!(coord, GridCoord::new(20, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grid_to_world_conversion() {
|
||||
let grid = GridStorage::new(100, 100, 0.05, WorldPoint::ZERO);
|
||||
|
||||
let point = grid.grid_to_world(GridCoord::new(0, 0));
|
||||
assert!((point.x - 0.025).abs() < 1e-6);
|
||||
assert!((point.y - 0.025).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_set_cell() {
|
||||
let mut grid = GridStorage::new(10, 10, 0.1, WorldPoint::ZERO);
|
||||
|
||||
assert_eq!(grid.get_type(GridCoord::new(5, 5)), CellType::Unknown);
|
||||
|
||||
grid.set_type(GridCoord::new(5, 5), CellType::Floor);
|
||||
assert_eq!(grid.get_type(GridCoord::new(5, 5)), CellType::Floor);
|
||||
|
||||
assert_eq!(grid.get_type(GridCoord::new(100, 100)), CellType::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grid_expansion() {
|
||||
let mut grid = GridStorage::centered(10, 10, 0.1);
|
||||
|
||||
assert_eq!(grid.width(), 10);
|
||||
assert_eq!(grid.height(), 10);
|
||||
|
||||
let far_point = WorldPoint::new(2.0, 2.0);
|
||||
assert!(grid.expand_to_include(far_point, 100, 100));
|
||||
|
||||
assert!(grid.width() > 10);
|
||||
assert!(grid.height() > 10);
|
||||
assert!(grid.contains_point(far_point));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cell_counts() {
|
||||
let mut grid = GridStorage::new(10, 10, 0.1, WorldPoint::ZERO);
|
||||
|
||||
grid.set_type(GridCoord::new(0, 0), CellType::Floor);
|
||||
grid.set_type(GridCoord::new(1, 0), CellType::Wall);
|
||||
grid.set_type(GridCoord::new(2, 0), CellType::Cliff);
|
||||
grid.set_type(GridCoord::new(3, 0), CellType::Bump);
|
||||
|
||||
let counts = grid.count_by_type();
|
||||
assert_eq!(counts.floor, 1);
|
||||
assert_eq!(counts.wall, 1);
|
||||
assert_eq!(counts.cliff, 1);
|
||||
assert_eq!(counts.bump, 1);
|
||||
assert_eq!(counts.unknown, 96);
|
||||
assert_eq!(counts.known(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_soa_iter() {
|
||||
let mut grid = GridStorage::new(10, 10, 0.1, WorldPoint::ZERO);
|
||||
grid.set_type(GridCoord::new(5, 5), CellType::Wall);
|
||||
|
||||
let mut found_wall = false;
|
||||
for (coord, cell) in grid.iter() {
|
||||
if coord == GridCoord::new(5, 5) {
|
||||
assert_eq!(cell.cell_type, CellType::Wall);
|
||||
found_wall = true;
|
||||
}
|
||||
}
|
||||
assert!(found_wall);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cell_priority() {
|
||||
let mut grid = GridStorage::new(10, 10, 0.1, WorldPoint::ZERO);
|
||||
|
||||
grid.set_type(GridCoord::new(0, 0), CellType::Floor);
|
||||
assert_eq!(grid.get_type(GridCoord::new(0, 0)), CellType::Floor);
|
||||
|
||||
grid.set_type_with_priority(GridCoord::new(0, 0), CellType::Wall);
|
||||
assert_eq!(grid.get_type(GridCoord::new(0, 0)), CellType::Wall);
|
||||
|
||||
grid.set_type_with_priority(GridCoord::new(0, 0), CellType::Floor);
|
||||
assert_eq!(grid.get_type(GridCoord::new(0, 0)), CellType::Wall);
|
||||
|
||||
grid.set_type_with_priority(GridCoord::new(0, 0), CellType::Bump);
|
||||
assert_eq!(grid.get_type(GridCoord::new(0, 0)), CellType::Bump);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_odds_probability_conversion() {
|
||||
assert!((GridStorage::log_odds_to_probability(0) - 0.5).abs() < 0.01);
|
||||
assert!((GridStorage::log_odds_to_probability(100) - 0.73).abs() < 0.01);
|
||||
assert!((GridStorage::log_odds_to_probability(-100) - 0.27).abs() < 0.01);
|
||||
assert!((GridStorage::log_odds_to_probability(200) - 0.88).abs() < 0.01);
|
||||
assert!((GridStorage::log_odds_to_probability(-200) - 0.12).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_odds_apply_hit() {
|
||||
let aggressive = LogOddsConfig::aggressive();
|
||||
let mut grid = GridStorage::centered_with_config(10, 10, 0.1, aggressive.clone());
|
||||
let coord = GridCoord::new(5, 5);
|
||||
|
||||
assert_eq!(grid.get_log_odds(coord), 0);
|
||||
assert!(!grid.is_occupied(coord));
|
||||
|
||||
for i in 0..3 {
|
||||
let became_occupied = grid.apply_hit(coord);
|
||||
if i == 0 {
|
||||
assert!(became_occupied);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(grid.is_occupied(coord));
|
||||
assert_eq!(grid.get_type(coord), CellType::Wall);
|
||||
|
||||
let log_odds = grid.get_log_odds(coord);
|
||||
assert!(log_odds > grid.log_odds_config().l_occupied_threshold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_odds_apply_miss() {
|
||||
let aggressive = LogOddsConfig::aggressive();
|
||||
let mut grid = GridStorage::centered_with_config(10, 10, 0.1, aggressive);
|
||||
let coord = GridCoord::new(5, 5);
|
||||
|
||||
for i in 0..5 {
|
||||
let became_free = grid.apply_miss(coord);
|
||||
if i == 1 {
|
||||
assert!(became_free);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(grid.is_free(coord));
|
||||
assert_eq!(grid.get_type(coord), CellType::Floor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_odds_cartographer_default() {
|
||||
let mut grid = GridStorage::new(10, 10, 0.1, WorldPoint::ZERO);
|
||||
let coord = GridCoord::new(5, 5);
|
||||
|
||||
assert!(!grid.apply_hit(coord));
|
||||
assert!(!grid.apply_hit(coord));
|
||||
assert!(grid.apply_hit(coord));
|
||||
|
||||
assert!(grid.is_occupied(coord));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_odds_balanced_config() {
|
||||
let mut grid = GridStorage::centered_with_config(10, 10, 0.1, LogOddsConfig::balanced());
|
||||
let coord = GridCoord::new(5, 5);
|
||||
|
||||
assert!(!grid.apply_hit(coord));
|
||||
assert!(grid.apply_hit(coord));
|
||||
|
||||
assert!(grid.is_occupied(coord));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_odds_conflicting_observations() {
|
||||
let aggressive = LogOddsConfig::aggressive();
|
||||
let mut grid = GridStorage::centered_with_config(10, 10, 0.1, aggressive);
|
||||
let coord = GridCoord::new(5, 5);
|
||||
|
||||
for _ in 0..3 {
|
||||
grid.apply_hit(coord);
|
||||
}
|
||||
assert!(grid.is_occupied(coord));
|
||||
let initial_log_odds = grid.get_log_odds(coord);
|
||||
|
||||
for _ in 0..2 {
|
||||
grid.apply_miss(coord);
|
||||
}
|
||||
|
||||
let after_miss = grid.get_log_odds(coord);
|
||||
assert!(after_miss < initial_log_odds);
|
||||
assert!(grid.is_occupied(coord));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_odds_clamping() {
|
||||
let mut grid = GridStorage::new(10, 10, 0.1, WorldPoint::ZERO);
|
||||
let coord = GridCoord::new(5, 5);
|
||||
let cfg = grid.log_odds_config().clone();
|
||||
|
||||
for _ in 0..100 {
|
||||
grid.apply_hit(coord);
|
||||
}
|
||||
assert_eq!(grid.get_log_odds(coord), cfg.l_max);
|
||||
|
||||
for _ in 0..100 {
|
||||
grid.apply_miss(coord);
|
||||
}
|
||||
assert_eq!(grid.get_log_odds(coord), cfg.l_min);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simd_count_matches_scalar() {
|
||||
let mut grid = GridStorage::new(100, 100, 0.05, WorldPoint::ZERO);
|
||||
|
||||
for i in 0..100 {
|
||||
grid.set_type(GridCoord::new(i, 0), CellType::Floor);
|
||||
grid.set_type(GridCoord::new(i, 1), CellType::Wall);
|
||||
}
|
||||
for i in 0..50 {
|
||||
grid.set_type(GridCoord::new(i, 2), CellType::Cliff);
|
||||
grid.set_type(GridCoord::new(i, 3), CellType::Bump);
|
||||
}
|
||||
|
||||
let simd_counts = grid.count_by_type();
|
||||
let scalar_counts = grid.count_by_type_scalar();
|
||||
|
||||
assert_eq!(simd_counts.unknown, scalar_counts.unknown);
|
||||
assert_eq!(simd_counts.floor, scalar_counts.floor);
|
||||
assert_eq!(simd_counts.wall, scalar_counts.wall);
|
||||
assert_eq!(simd_counts.cliff, scalar_counts.cliff);
|
||||
assert_eq!(simd_counts.bump, scalar_counts.bump);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Types used by grid storage.
|
||||
|
||||
use crate::core::CellType;
|
||||
|
||||
/// Mutable reference to a single cell's fields in the SoA storage.
|
||||
///
|
||||
/// This allows modifying individual cell properties without reconstructing
|
||||
/// the entire Cell struct.
|
||||
pub struct CellMut<'a> {
|
||||
/// Cell type as raw u8.
|
||||
pub cell_type: &'a mut u8,
|
||||
/// Confidence level.
|
||||
pub confidence: &'a mut u8,
|
||||
/// Observation count.
|
||||
pub observation_count: &'a mut u8,
|
||||
/// Swept flag (0 = not swept, 1 = swept).
|
||||
pub swept: &'a mut u8,
|
||||
}
|
||||
|
||||
impl<'a> CellMut<'a> {
|
||||
/// Get the cell type.
|
||||
#[inline]
|
||||
pub fn get_type(&self) -> CellType {
|
||||
CellType::from_u8(*self.cell_type)
|
||||
}
|
||||
|
||||
/// Set the cell type.
|
||||
#[inline]
|
||||
pub fn set_type(&mut self, cell_type: CellType) {
|
||||
*self.cell_type = cell_type as u8;
|
||||
}
|
||||
|
||||
/// Check if traversable.
|
||||
#[inline]
|
||||
pub fn is_traversable(&self) -> bool {
|
||||
*self.cell_type == CellType::Floor as u8
|
||||
}
|
||||
|
||||
/// Check if obstacle.
|
||||
#[inline]
|
||||
pub fn is_obstacle(&self) -> bool {
|
||||
let t = *self.cell_type;
|
||||
t == CellType::Wall as u8 || t == CellType::Cliff as u8 || t == CellType::Bump as u8
|
||||
}
|
||||
|
||||
/// Update with a new observation.
|
||||
pub fn observe(&mut self, observed_type: CellType) -> bool {
|
||||
let new_type = observed_type as u8;
|
||||
let changed = *self.cell_type != new_type;
|
||||
|
||||
if changed {
|
||||
*self.cell_type = new_type;
|
||||
*self.confidence = 1;
|
||||
*self.observation_count = 1;
|
||||
} else {
|
||||
*self.confidence = self.confidence.saturating_add(1);
|
||||
*self.observation_count = self.observation_count.saturating_add(1);
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
/// Update with priority rules.
|
||||
pub fn observe_with_priority(&mut self, observed_type: CellType) -> bool {
|
||||
let old_type = CellType::from_u8(*self.cell_type);
|
||||
let should_update = match (old_type, observed_type) {
|
||||
(CellType::Unknown, _) => true,
|
||||
(CellType::Bump, CellType::Bump) => true,
|
||||
(CellType::Bump, _) => false,
|
||||
(CellType::Cliff, CellType::Bump | CellType::Cliff) => true,
|
||||
(CellType::Cliff, _) => false,
|
||||
(CellType::Wall, CellType::Bump | CellType::Cliff | CellType::Wall) => true,
|
||||
(CellType::Wall, _) => false,
|
||||
(CellType::Floor, _) => true,
|
||||
};
|
||||
|
||||
if should_update {
|
||||
self.observe(observed_type)
|
||||
} else {
|
||||
*self.observation_count = self.observation_count.saturating_add(1);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cell counts by type.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct CellCounts {
|
||||
/// Unknown cells (not yet observed).
|
||||
pub unknown: usize,
|
||||
/// Floor cells (traversable).
|
||||
pub floor: usize,
|
||||
/// Wall cells (lidar-detected obstacles).
|
||||
pub wall: usize,
|
||||
/// Cliff cells (floor drop-offs).
|
||||
pub cliff: usize,
|
||||
/// Bump cells (invisible obstacles).
|
||||
pub bump: usize,
|
||||
}
|
||||
|
||||
impl CellCounts {
|
||||
/// Total known cells.
|
||||
pub fn known(&self) -> usize {
|
||||
self.floor + self.wall + self.cliff + self.bump
|
||||
}
|
||||
|
||||
/// Total cells.
|
||||
pub fn total(&self) -> usize {
|
||||
self.unknown + self.known()
|
||||
}
|
||||
|
||||
/// Total obstacle cells.
|
||||
pub fn obstacles(&self) -> usize {
|
||||
self.wall + self.cliff + self.bump
|
||||
}
|
||||
}
|
||||
@@ -60,9 +60,6 @@ pub mod query;
|
||||
// Path planning
|
||||
pub mod pathfinding;
|
||||
|
||||
// Exploration controller
|
||||
pub mod exploration;
|
||||
|
||||
// SLAM (scan matching, loop closure)
|
||||
pub mod slam;
|
||||
|
||||
@@ -72,6 +69,12 @@ pub mod submap;
|
||||
// High-level exploration API
|
||||
pub mod explore;
|
||||
|
||||
/// Backward compatibility: re-export exploration types from explore::controller
|
||||
#[deprecated(note = "Use `explore::*` instead - exploration types are now in explore module")]
|
||||
pub mod exploration {
|
||||
pub use crate::explore::controller::*;
|
||||
}
|
||||
|
||||
// Persistence (save/load)
|
||||
pub mod io;
|
||||
|
||||
|
||||
@@ -1,822 +0,0 @@
|
||||
//! A* pathfinding algorithm.
|
||||
//!
|
||||
//! Implements A* search on the occupancy grid with support for:
|
||||
//! - Robot footprint collision checking
|
||||
//! - Safety margin around obstacles
|
||||
//! - 8-connected grid movement
|
||||
|
||||
use crate::core::{GridCoord, WorldPoint};
|
||||
use crate::grid::GridStorage;
|
||||
use crate::query::{RobotFootprint, TraversabilityChecker};
|
||||
use log::{debug, trace};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BinaryHeap, HashMap, HashSet};
|
||||
|
||||
/// A node in the A* search
|
||||
#[derive(Clone, Debug)]
|
||||
struct AStarNode {
|
||||
coord: GridCoord,
|
||||
g_cost: f32, // Cost from start
|
||||
f_cost: f32, // g_cost + heuristic
|
||||
}
|
||||
|
||||
impl Eq for AStarNode {}
|
||||
|
||||
impl PartialEq for AStarNode {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.coord == other.coord
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for AStarNode {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
// Reverse ordering for min-heap behavior
|
||||
other
|
||||
.f_cost
|
||||
.partial_cmp(&self.f_cost)
|
||||
.unwrap_or(Ordering::Equal)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for AStarNode {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
/// A* pathfinding configuration
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AStarConfig {
|
||||
/// Robot footprint for collision checking
|
||||
pub footprint: RobotFootprint,
|
||||
/// Allow diagonal movement (8-connected vs 4-connected)
|
||||
pub allow_diagonal: bool,
|
||||
/// Diagonal movement cost multiplier (sqrt(2) ≈ 1.414)
|
||||
pub diagonal_cost: f32,
|
||||
/// Maximum number of nodes to expand before giving up
|
||||
pub max_iterations: usize,
|
||||
/// Prefer paths with more clearance from obstacles
|
||||
pub clearance_weight: f32,
|
||||
/// Exploration mode: allow Unknown cells in footprint
|
||||
/// (assumes unknown areas are likely free)
|
||||
pub exploration_mode: bool,
|
||||
}
|
||||
|
||||
impl Default for AStarConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
footprint: RobotFootprint::default(),
|
||||
allow_diagonal: true,
|
||||
diagonal_cost: std::f32::consts::SQRT_2,
|
||||
max_iterations: 100_000,
|
||||
clearance_weight: 0.0, // Disabled by default for performance
|
||||
exploration_mode: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AStarConfig {
|
||||
/// Create with custom robot footprint
|
||||
pub fn with_footprint(footprint: RobotFootprint) -> Self {
|
||||
Self {
|
||||
footprint,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable clearance-weighted pathfinding
|
||||
pub fn with_clearance_weight(mut self, weight: f32) -> Self {
|
||||
self.clearance_weight = weight;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of A* pathfinding
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PathResult {
|
||||
/// Path as grid coordinates (empty if no path found)
|
||||
pub path_grid: Vec<GridCoord>,
|
||||
/// Path as world coordinates
|
||||
pub path_world: Vec<WorldPoint>,
|
||||
/// Total path cost
|
||||
pub cost: f32,
|
||||
/// Number of nodes expanded during search
|
||||
pub nodes_expanded: usize,
|
||||
/// Whether a path was found
|
||||
pub success: bool,
|
||||
/// Reason for failure (if any)
|
||||
pub failure_reason: Option<PathFailure>,
|
||||
}
|
||||
|
||||
impl PathResult {
|
||||
/// Create a failed result
|
||||
fn failed(reason: PathFailure, nodes_expanded: usize) -> Self {
|
||||
Self {
|
||||
path_grid: Vec::new(),
|
||||
path_world: Vec::new(),
|
||||
cost: f32::INFINITY,
|
||||
nodes_expanded,
|
||||
success: false,
|
||||
failure_reason: Some(reason),
|
||||
}
|
||||
}
|
||||
|
||||
/// Path length in cells
|
||||
pub fn length_cells(&self) -> usize {
|
||||
self.path_grid.len()
|
||||
}
|
||||
|
||||
/// Path length in meters (approximate)
|
||||
pub fn length_meters(&self) -> f32 {
|
||||
if self.path_world.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut total = 0.0;
|
||||
for i in 1..self.path_world.len() {
|
||||
total += self.path_world[i - 1].distance(&self.path_world[i]);
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
|
||||
/// Reason for path failure
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PathFailure {
|
||||
/// Start position is not traversable
|
||||
StartBlocked,
|
||||
/// Goal position is not traversable
|
||||
GoalBlocked,
|
||||
/// No path exists between start and goal
|
||||
NoPath,
|
||||
/// Maximum iterations exceeded
|
||||
MaxIterationsExceeded,
|
||||
/// Start or goal is out of bounds
|
||||
OutOfBounds,
|
||||
}
|
||||
|
||||
/// A* pathfinder
|
||||
pub struct AStarPlanner<'a> {
|
||||
storage: &'a GridStorage,
|
||||
config: AStarConfig,
|
||||
}
|
||||
|
||||
impl<'a> AStarPlanner<'a> {
|
||||
/// Create a new A* planner
|
||||
pub fn new(storage: &'a GridStorage, config: AStarConfig) -> Self {
|
||||
Self { storage, config }
|
||||
}
|
||||
|
||||
/// Create with default configuration
|
||||
pub fn with_defaults(storage: &'a GridStorage) -> Self {
|
||||
Self::new(storage, AStarConfig::default())
|
||||
}
|
||||
|
||||
/// Find a path from start to goal (grid coordinates)
|
||||
pub fn find_path(&self, start: GridCoord, goal: GridCoord) -> PathResult {
|
||||
trace!(
|
||||
"[AStar] find_path: start=({},{}) goal=({},{})",
|
||||
start.x, start.y, goal.x, goal.y
|
||||
);
|
||||
|
||||
// Check bounds
|
||||
if !self.storage.is_valid_coord(start) || !self.storage.is_valid_coord(goal) {
|
||||
debug!("[AStar] FAILED: OutOfBounds - start or goal outside grid");
|
||||
return PathResult::failed(PathFailure::OutOfBounds, 0);
|
||||
}
|
||||
|
||||
// Check start and goal traversability using footprint
|
||||
let checker = TraversabilityChecker::new(self.storage, self.config.footprint.clone());
|
||||
|
||||
let start_world = self.storage.grid_to_world(start);
|
||||
let goal_world = self.storage.grid_to_world(goal);
|
||||
|
||||
if !checker.is_position_safe_mode(start_world, self.config.exploration_mode) {
|
||||
debug!(
|
||||
"[AStar] FAILED: StartBlocked at ({:.2},{:.2}), exploration_mode={}",
|
||||
start_world.x, start_world.y, self.config.exploration_mode
|
||||
);
|
||||
return PathResult::failed(PathFailure::StartBlocked, 0);
|
||||
}
|
||||
if !checker.is_position_safe_mode(goal_world, self.config.exploration_mode) {
|
||||
debug!(
|
||||
"[AStar] FAILED: GoalBlocked at ({:.2},{:.2}), exploration_mode={}",
|
||||
goal_world.x, goal_world.y, self.config.exploration_mode
|
||||
);
|
||||
return PathResult::failed(PathFailure::GoalBlocked, 0);
|
||||
}
|
||||
|
||||
// A* search
|
||||
let mut open_set = BinaryHeap::new();
|
||||
let mut closed_set = HashSet::new();
|
||||
let mut came_from: HashMap<GridCoord, GridCoord> = HashMap::new();
|
||||
let mut g_scores: HashMap<GridCoord, f32> = HashMap::new();
|
||||
|
||||
let h_start = self.heuristic(start, goal);
|
||||
open_set.push(AStarNode {
|
||||
coord: start,
|
||||
g_cost: 0.0,
|
||||
f_cost: h_start,
|
||||
});
|
||||
g_scores.insert(start, 0.0);
|
||||
|
||||
let mut nodes_expanded = 0;
|
||||
|
||||
while let Some(current) = open_set.pop() {
|
||||
nodes_expanded += 1;
|
||||
|
||||
if nodes_expanded > self.config.max_iterations {
|
||||
debug!(
|
||||
"[AStar] FAILED: MaxIterationsExceeded ({} nodes)",
|
||||
nodes_expanded
|
||||
);
|
||||
return PathResult::failed(PathFailure::MaxIterationsExceeded, nodes_expanded);
|
||||
}
|
||||
|
||||
// Goal reached
|
||||
if current.coord == goal {
|
||||
return self.reconstruct_path(came_from, goal, current.g_cost, nodes_expanded);
|
||||
}
|
||||
|
||||
if closed_set.contains(¤t.coord) {
|
||||
continue;
|
||||
}
|
||||
closed_set.insert(current.coord);
|
||||
|
||||
// Explore neighbors
|
||||
let neighbors = if self.config.allow_diagonal {
|
||||
current.coord.neighbors_8().to_vec()
|
||||
} else {
|
||||
current.coord.neighbors_4().to_vec()
|
||||
};
|
||||
|
||||
for (i, neighbor) in neighbors.iter().enumerate() {
|
||||
if closed_set.contains(neighbor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !self.storage.is_valid_coord(*neighbor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check traversability with footprint
|
||||
let neighbor_world = self.storage.grid_to_world(*neighbor);
|
||||
if !checker.is_position_safe_mode(neighbor_world, self.config.exploration_mode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate movement cost
|
||||
let is_diagonal = self.config.allow_diagonal && i >= 4;
|
||||
let move_cost = if is_diagonal {
|
||||
self.config.diagonal_cost
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
// Add clearance cost if enabled
|
||||
let clearance_cost = if self.config.clearance_weight > 0.0 {
|
||||
let clearance = checker.clearance(neighbor_world, 1.0);
|
||||
// Penalize positions close to obstacles
|
||||
self.config.clearance_weight / (clearance + 0.1)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let tentative_g = g_scores[¤t.coord] + move_cost + clearance_cost;
|
||||
|
||||
let current_g = g_scores.get(neighbor).copied().unwrap_or(f32::INFINITY);
|
||||
if tentative_g < current_g {
|
||||
came_from.insert(*neighbor, current.coord);
|
||||
g_scores.insert(*neighbor, tentative_g);
|
||||
|
||||
let h = self.heuristic(*neighbor, goal);
|
||||
open_set.push(AStarNode {
|
||||
coord: *neighbor,
|
||||
g_cost: tentative_g,
|
||||
f_cost: tentative_g + h,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
"[AStar] FAILED: NoPath after expanding {} nodes",
|
||||
nodes_expanded
|
||||
);
|
||||
PathResult::failed(PathFailure::NoPath, nodes_expanded)
|
||||
}
|
||||
|
||||
/// Find a path from start to goal (world coordinates)
|
||||
pub fn find_path_world(&self, start: WorldPoint, goal: WorldPoint) -> PathResult {
|
||||
let start_grid = self.storage.world_to_grid(start);
|
||||
let goal_grid = self.storage.world_to_grid(goal);
|
||||
self.find_path(start_grid, goal_grid)
|
||||
}
|
||||
|
||||
/// Heuristic function (octile distance for 8-connected grid)
|
||||
fn heuristic(&self, from: GridCoord, to: GridCoord) -> f32 {
|
||||
let dx = (from.x - to.x).abs() as f32;
|
||||
let dy = (from.y - to.y).abs() as f32;
|
||||
|
||||
if self.config.allow_diagonal {
|
||||
// Octile distance
|
||||
let min = dx.min(dy);
|
||||
let max = dx.max(dy);
|
||||
min * self.config.diagonal_cost + (max - min)
|
||||
} else {
|
||||
// Manhattan distance
|
||||
dx + dy
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct the path from came_from map
|
||||
fn reconstruct_path(
|
||||
&self,
|
||||
came_from: HashMap<GridCoord, GridCoord>,
|
||||
goal: GridCoord,
|
||||
cost: f32,
|
||||
nodes_expanded: usize,
|
||||
) -> PathResult {
|
||||
let mut path_grid = Vec::new();
|
||||
let mut current = goal;
|
||||
|
||||
while let Some(&prev) = came_from.get(¤t) {
|
||||
path_grid.push(current);
|
||||
current = prev;
|
||||
}
|
||||
path_grid.push(current); // Add start
|
||||
path_grid.reverse();
|
||||
|
||||
// Convert to world coordinates
|
||||
let path_world: Vec<WorldPoint> = path_grid
|
||||
.iter()
|
||||
.map(|c| self.storage.grid_to_world(*c))
|
||||
.collect();
|
||||
|
||||
trace!(
|
||||
"[AStar] SUCCESS: path length={} cells, cost={:.2}, nodes_expanded={}",
|
||||
path_grid.len(),
|
||||
cost,
|
||||
nodes_expanded
|
||||
);
|
||||
|
||||
PathResult {
|
||||
path_grid,
|
||||
path_world,
|
||||
cost,
|
||||
nodes_expanded,
|
||||
success: true,
|
||||
failure_reason: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick path finding with default configuration
|
||||
pub fn find_path(storage: &GridStorage, start: WorldPoint, goal: WorldPoint) -> PathResult {
|
||||
let planner = AStarPlanner::with_defaults(storage);
|
||||
planner.find_path_world(start, goal)
|
||||
}
|
||||
|
||||
/// Check if a path exists (faster than full path computation)
|
||||
pub fn path_exists(storage: &GridStorage, start: WorldPoint, goal: WorldPoint) -> bool {
|
||||
find_path(storage, start, goal).success
|
||||
}
|
||||
|
||||
/// A* planner with pre-computed traversability cache.
|
||||
///
|
||||
/// This is faster for multiple path queries on the same grid state,
|
||||
/// as it pre-computes traversability for all cells once.
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,ignore
|
||||
/// let cached = CachedAStarPlanner::new(storage, config);
|
||||
/// let path1 = cached.find_path(start1, goal1);
|
||||
/// let path2 = cached.find_path(start2, goal2); // Uses cached traversability
|
||||
/// ```
|
||||
pub struct CachedAStarPlanner<'a> {
|
||||
storage: &'a GridStorage,
|
||||
config: AStarConfig,
|
||||
/// Pre-computed traversability for each cell (true = safe to traverse)
|
||||
traversability_cache: Vec<bool>,
|
||||
}
|
||||
|
||||
impl<'a> CachedAStarPlanner<'a> {
|
||||
/// Create a new cached A* planner.
|
||||
///
|
||||
/// Pre-computes traversability for all cells based on the robot footprint.
|
||||
/// This has O(width × height × footprint_area) upfront cost but makes
|
||||
/// each pathfinding query O(1) for traversability checks.
|
||||
pub fn new(storage: &'a GridStorage, config: AStarConfig) -> Self {
|
||||
let checker = TraversabilityChecker::new(storage, config.footprint.clone());
|
||||
let width = storage.width();
|
||||
let height = storage.height();
|
||||
let exploration_mode = config.exploration_mode;
|
||||
|
||||
// Pre-compute traversability for all cells
|
||||
let mut cache = vec![false; width * height];
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let coord = GridCoord::new(x as i32, y as i32);
|
||||
let world = storage.grid_to_world(coord);
|
||||
cache[y * width + x] = checker.is_position_safe_mode(world, exploration_mode);
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
storage,
|
||||
config,
|
||||
traversability_cache: cache,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with default configuration
|
||||
pub fn with_defaults(storage: &'a GridStorage) -> Self {
|
||||
Self::new(storage, AStarConfig::default())
|
||||
}
|
||||
|
||||
/// Check if a cell is traversable using the cache
|
||||
#[inline]
|
||||
fn is_traversable(&self, coord: GridCoord) -> bool {
|
||||
if !self.storage.is_valid_coord(coord) {
|
||||
return false;
|
||||
}
|
||||
let idx = coord.y as usize * self.storage.width() + coord.x as usize;
|
||||
self.traversability_cache[idx]
|
||||
}
|
||||
|
||||
/// Find a path from start to goal (grid coordinates)
|
||||
pub fn find_path(&self, start: GridCoord, goal: GridCoord) -> PathResult {
|
||||
trace!(
|
||||
"[CachedAStar] find_path: start=({},{}) goal=({},{})",
|
||||
start.x, start.y, goal.x, goal.y
|
||||
);
|
||||
|
||||
// Check bounds
|
||||
if !self.storage.is_valid_coord(start) || !self.storage.is_valid_coord(goal) {
|
||||
debug!("[CachedAStar] FAILED: OutOfBounds - start or goal outside grid");
|
||||
return PathResult::failed(PathFailure::OutOfBounds, 0);
|
||||
}
|
||||
|
||||
// Check start and goal traversability using cache
|
||||
if !self.is_traversable(start) {
|
||||
debug!("[CachedAStar] FAILED: StartBlocked");
|
||||
return PathResult::failed(PathFailure::StartBlocked, 0);
|
||||
}
|
||||
if !self.is_traversable(goal) {
|
||||
debug!("[CachedAStar] FAILED: GoalBlocked");
|
||||
return PathResult::failed(PathFailure::GoalBlocked, 0);
|
||||
}
|
||||
|
||||
// A* search
|
||||
let mut open_set = BinaryHeap::new();
|
||||
let mut closed_set = HashSet::new();
|
||||
let mut came_from: HashMap<GridCoord, GridCoord> = HashMap::new();
|
||||
let mut g_scores: HashMap<GridCoord, f32> = HashMap::new();
|
||||
|
||||
let h_start = self.heuristic(start, goal);
|
||||
open_set.push(AStarNode {
|
||||
coord: start,
|
||||
g_cost: 0.0,
|
||||
f_cost: h_start,
|
||||
});
|
||||
g_scores.insert(start, 0.0);
|
||||
|
||||
let mut nodes_expanded = 0;
|
||||
|
||||
while let Some(current) = open_set.pop() {
|
||||
nodes_expanded += 1;
|
||||
|
||||
if nodes_expanded > self.config.max_iterations {
|
||||
debug!(
|
||||
"[CachedAStar] FAILED: MaxIterationsExceeded ({} nodes)",
|
||||
nodes_expanded
|
||||
);
|
||||
return PathResult::failed(PathFailure::MaxIterationsExceeded, nodes_expanded);
|
||||
}
|
||||
|
||||
// Goal reached
|
||||
if current.coord == goal {
|
||||
return self.reconstruct_path(came_from, goal, current.g_cost, nodes_expanded);
|
||||
}
|
||||
|
||||
if closed_set.contains(¤t.coord) {
|
||||
continue;
|
||||
}
|
||||
closed_set.insert(current.coord);
|
||||
|
||||
// Explore neighbors
|
||||
let neighbors = if self.config.allow_diagonal {
|
||||
current.coord.neighbors_8().to_vec()
|
||||
} else {
|
||||
current.coord.neighbors_4().to_vec()
|
||||
};
|
||||
|
||||
for (i, neighbor) in neighbors.iter().enumerate() {
|
||||
if closed_set.contains(neighbor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check traversability using cache (O(1) lookup)
|
||||
if !self.is_traversable(*neighbor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate movement cost
|
||||
let is_diagonal = self.config.allow_diagonal && i >= 4;
|
||||
let move_cost = if is_diagonal {
|
||||
self.config.diagonal_cost
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let tentative_g = g_scores[¤t.coord] + move_cost;
|
||||
|
||||
let current_g = g_scores.get(neighbor).copied().unwrap_or(f32::INFINITY);
|
||||
if tentative_g < current_g {
|
||||
came_from.insert(*neighbor, current.coord);
|
||||
g_scores.insert(*neighbor, tentative_g);
|
||||
|
||||
let h = self.heuristic(*neighbor, goal);
|
||||
open_set.push(AStarNode {
|
||||
coord: *neighbor,
|
||||
g_cost: tentative_g,
|
||||
f_cost: tentative_g + h,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
"[CachedAStar] FAILED: NoPath after expanding {} nodes",
|
||||
nodes_expanded
|
||||
);
|
||||
PathResult::failed(PathFailure::NoPath, nodes_expanded)
|
||||
}
|
||||
|
||||
/// Find a path from start to goal (world coordinates)
|
||||
pub fn find_path_world(&self, start: WorldPoint, goal: WorldPoint) -> PathResult {
|
||||
let start_grid = self.storage.world_to_grid(start);
|
||||
let goal_grid = self.storage.world_to_grid(goal);
|
||||
self.find_path(start_grid, goal_grid)
|
||||
}
|
||||
|
||||
/// Heuristic function (octile distance for 8-connected grid)
|
||||
fn heuristic(&self, from: GridCoord, to: GridCoord) -> f32 {
|
||||
let dx = (from.x - to.x).abs() as f32;
|
||||
let dy = (from.y - to.y).abs() as f32;
|
||||
|
||||
if self.config.allow_diagonal {
|
||||
let min = dx.min(dy);
|
||||
let max = dx.max(dy);
|
||||
min * self.config.diagonal_cost + (max - min)
|
||||
} else {
|
||||
dx + dy
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct the path from came_from map
|
||||
fn reconstruct_path(
|
||||
&self,
|
||||
came_from: HashMap<GridCoord, GridCoord>,
|
||||
goal: GridCoord,
|
||||
cost: f32,
|
||||
nodes_expanded: usize,
|
||||
) -> PathResult {
|
||||
let mut path_grid = Vec::new();
|
||||
let mut current = goal;
|
||||
|
||||
while let Some(&prev) = came_from.get(¤t) {
|
||||
path_grid.push(current);
|
||||
current = prev;
|
||||
}
|
||||
path_grid.push(current); // Add start
|
||||
path_grid.reverse();
|
||||
|
||||
let path_world: Vec<WorldPoint> = path_grid
|
||||
.iter()
|
||||
.map(|c| self.storage.grid_to_world(*c))
|
||||
.collect();
|
||||
|
||||
trace!(
|
||||
"[CachedAStar] SUCCESS: path length={} cells, cost={:.2}, nodes_expanded={}",
|
||||
path_grid.len(),
|
||||
cost,
|
||||
nodes_expanded
|
||||
);
|
||||
|
||||
PathResult {
|
||||
path_grid,
|
||||
path_world,
|
||||
cost,
|
||||
nodes_expanded,
|
||||
success: true,
|
||||
failure_reason: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::CellType;
|
||||
|
||||
fn create_test_storage() -> GridStorage {
|
||||
GridStorage::centered(50, 50, 0.1) // 5m x 5m at 10cm resolution
|
||||
}
|
||||
|
||||
fn fill_floor(storage: &mut GridStorage) {
|
||||
for x in 0..50 {
|
||||
for y in 0..50 {
|
||||
storage.set_type(GridCoord::new(x, y), CellType::Floor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_path() {
|
||||
let mut storage = create_test_storage();
|
||||
fill_floor(&mut storage);
|
||||
|
||||
// Small footprint for this test
|
||||
let config = AStarConfig::with_footprint(RobotFootprint::new(0.05, 0.01));
|
||||
let planner = AStarPlanner::new(&storage, config);
|
||||
|
||||
let start = GridCoord::new(10, 25);
|
||||
let goal = GridCoord::new(40, 25);
|
||||
|
||||
let result = planner.find_path(start, goal);
|
||||
|
||||
assert!(result.success);
|
||||
assert!(!result.path_grid.is_empty());
|
||||
assert_eq!(result.path_grid[0], start);
|
||||
assert_eq!(*result.path_grid.last().unwrap(), goal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_around_obstacle() {
|
||||
let mut storage = create_test_storage();
|
||||
fill_floor(&mut storage);
|
||||
|
||||
// Add a wall across the middle
|
||||
for y in 15..35 {
|
||||
storage.set_type_with_priority(GridCoord::new(25, y), CellType::Wall);
|
||||
}
|
||||
|
||||
let config = AStarConfig::with_footprint(RobotFootprint::new(0.05, 0.01));
|
||||
let planner = AStarPlanner::new(&storage, config);
|
||||
|
||||
let start = GridCoord::new(10, 25);
|
||||
let goal = GridCoord::new(40, 25);
|
||||
|
||||
let result = planner.find_path(start, goal);
|
||||
|
||||
assert!(result.success);
|
||||
// Path should go around the wall
|
||||
assert!(result.path_grid.len() > 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_path() {
|
||||
let mut storage = create_test_storage();
|
||||
fill_floor(&mut storage);
|
||||
|
||||
// Add a complete wall barrier
|
||||
for y in 0..50 {
|
||||
storage.set_type_with_priority(GridCoord::new(25, y), CellType::Wall);
|
||||
}
|
||||
|
||||
let config = AStarConfig::with_footprint(RobotFootprint::new(0.05, 0.01));
|
||||
let planner = AStarPlanner::new(&storage, config);
|
||||
|
||||
let start = GridCoord::new(10, 25);
|
||||
let goal = GridCoord::new(40, 25);
|
||||
|
||||
let result = planner.find_path(start, goal);
|
||||
|
||||
assert!(!result.success);
|
||||
assert_eq!(result.failure_reason, Some(PathFailure::NoPath));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_start_blocked() {
|
||||
let mut storage = create_test_storage();
|
||||
fill_floor(&mut storage);
|
||||
|
||||
// Block start position
|
||||
storage.set_type_with_priority(GridCoord::new(10, 25), CellType::Wall);
|
||||
|
||||
let config = AStarConfig::with_footprint(RobotFootprint::new(0.05, 0.01));
|
||||
let planner = AStarPlanner::new(&storage, config);
|
||||
|
||||
let start = GridCoord::new(10, 25);
|
||||
let goal = GridCoord::new(40, 25);
|
||||
|
||||
let result = planner.find_path(start, goal);
|
||||
|
||||
assert!(!result.success);
|
||||
assert_eq!(result.failure_reason, Some(PathFailure::StartBlocked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_goal_blocked() {
|
||||
let mut storage = create_test_storage();
|
||||
fill_floor(&mut storage);
|
||||
|
||||
// Block goal position
|
||||
storage.set_type_with_priority(GridCoord::new(40, 25), CellType::Wall);
|
||||
|
||||
let config = AStarConfig::with_footprint(RobotFootprint::new(0.05, 0.01));
|
||||
let planner = AStarPlanner::new(&storage, config);
|
||||
|
||||
let start = GridCoord::new(10, 25);
|
||||
let goal = GridCoord::new(40, 25);
|
||||
|
||||
let result = planner.find_path(start, goal);
|
||||
|
||||
assert!(!result.success);
|
||||
assert_eq!(result.failure_reason, Some(PathFailure::GoalBlocked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diagonal_path() {
|
||||
let mut storage = create_test_storage();
|
||||
fill_floor(&mut storage);
|
||||
|
||||
let config = AStarConfig {
|
||||
footprint: RobotFootprint::new(0.05, 0.01),
|
||||
allow_diagonal: true,
|
||||
..Default::default()
|
||||
};
|
||||
let planner = AStarPlanner::new(&storage, config);
|
||||
|
||||
let start = GridCoord::new(10, 10);
|
||||
let goal = GridCoord::new(40, 40);
|
||||
|
||||
let result = planner.find_path(start, goal);
|
||||
|
||||
assert!(result.success);
|
||||
// Diagonal path should be shorter than manhattan
|
||||
// For a 30x30 diagonal, manhattan would be 60, octile should be ~42
|
||||
assert!(result.path_grid.len() < 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_4_connected_path() {
|
||||
let mut storage = create_test_storage();
|
||||
fill_floor(&mut storage);
|
||||
|
||||
let config = AStarConfig {
|
||||
footprint: RobotFootprint::new(0.05, 0.01),
|
||||
allow_diagonal: false,
|
||||
..Default::default()
|
||||
};
|
||||
let planner = AStarPlanner::new(&storage, config);
|
||||
|
||||
let start = GridCoord::new(10, 10);
|
||||
let goal = GridCoord::new(40, 40);
|
||||
|
||||
let result = planner.find_path(start, goal);
|
||||
|
||||
assert!(result.success);
|
||||
// 4-connected path should follow manhattan distance
|
||||
// Path length should be approximately dx + dy = 60
|
||||
assert!(result.path_grid.len() >= 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_world_coordinates() {
|
||||
let mut storage = create_test_storage();
|
||||
fill_floor(&mut storage);
|
||||
|
||||
let config = AStarConfig::with_footprint(RobotFootprint::new(0.05, 0.01));
|
||||
let planner = AStarPlanner::new(&storage, config);
|
||||
|
||||
let start = WorldPoint::new(-1.0, 0.0);
|
||||
let goal = WorldPoint::new(1.0, 0.0);
|
||||
|
||||
let result = planner.find_path_world(start, goal);
|
||||
|
||||
assert!(result.success);
|
||||
assert!(result.path_world.len() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_length_meters() {
|
||||
let mut storage = create_test_storage();
|
||||
fill_floor(&mut storage);
|
||||
|
||||
let config = AStarConfig::with_footprint(RobotFootprint::new(0.05, 0.01));
|
||||
let planner = AStarPlanner::new(&storage, config);
|
||||
|
||||
// Path of 2 meters (20 cells at 10cm resolution)
|
||||
let start = WorldPoint::new(-1.0, 0.0);
|
||||
let goal = WorldPoint::new(1.0, 0.0);
|
||||
|
||||
let result = planner.find_path_world(start, goal);
|
||||
|
||||
assert!(result.success);
|
||||
// Path length should be approximately 2.0 meters
|
||||
let length = result.length_meters();
|
||||
assert!(length > 1.5 && length < 2.5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Cached A* planner with pre-computed traversability.
|
||||
|
||||
use crate::core::{GridCoord, WorldPoint};
|
||||
use crate::grid::GridStorage;
|
||||
use crate::query::TraversabilityChecker;
|
||||
use log::{debug, trace};
|
||||
use std::collections::{BinaryHeap, HashMap, HashSet};
|
||||
|
||||
use super::types::{AStarConfig, AStarNode, PathFailure, PathResult};
|
||||
|
||||
/// A* planner with pre-computed traversability cache.
|
||||
///
|
||||
/// This is faster for multiple path queries on the same grid state,
|
||||
/// as it pre-computes traversability for all cells once.
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,ignore
|
||||
/// let cached = CachedAStarPlanner::new(storage, config);
|
||||
/// let path1 = cached.find_path(start1, goal1);
|
||||
/// let path2 = cached.find_path(start2, goal2); // Uses cached traversability
|
||||
/// ```
|
||||
pub struct CachedAStarPlanner<'a> {
|
||||
storage: &'a GridStorage,
|
||||
config: AStarConfig,
|
||||
/// Pre-computed traversability for each cell (true = safe to traverse)
|
||||
traversability_cache: Vec<bool>,
|
||||
}
|
||||
|
||||
impl<'a> CachedAStarPlanner<'a> {
|
||||
/// Create a new cached A* planner.
|
||||
///
|
||||
/// Pre-computes traversability for all cells based on the robot footprint.
|
||||
/// This has O(width × height × footprint_area) upfront cost but makes
|
||||
/// each pathfinding query O(1) for traversability checks.
|
||||
pub fn new(storage: &'a GridStorage, config: AStarConfig) -> Self {
|
||||
let checker = TraversabilityChecker::new(storage, config.footprint.clone());
|
||||
let width = storage.width();
|
||||
let height = storage.height();
|
||||
let exploration_mode = config.exploration_mode;
|
||||
|
||||
// Pre-compute traversability for all cells
|
||||
let mut cache = vec![false; width * height];
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let coord = GridCoord::new(x as i32, y as i32);
|
||||
let world = storage.grid_to_world(coord);
|
||||
cache[y * width + x] = checker.is_position_safe_mode(world, exploration_mode);
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
storage,
|
||||
config,
|
||||
traversability_cache: cache,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with default configuration
|
||||
pub fn with_defaults(storage: &'a GridStorage) -> Self {
|
||||
Self::new(storage, AStarConfig::default())
|
||||
}
|
||||
|
||||
/// Check if a cell is traversable using the cache
|
||||
#[inline]
|
||||
fn is_traversable(&self, coord: GridCoord) -> bool {
|
||||
if !self.storage.is_valid_coord(coord) {
|
||||
return false;
|
||||
}
|
||||
let idx = coord.y as usize * self.storage.width() + coord.x as usize;
|
||||
self.traversability_cache[idx]
|
||||
}
|
||||
|
||||
/// Find a path from start to goal (grid coordinates)
|
||||
pub fn find_path(&self, start: GridCoord, goal: GridCoord) -> PathResult {
|
||||
trace!(
|
||||
"[CachedAStar] find_path: start=({},{}) goal=({},{})",
|
||||
start.x, start.y, goal.x, goal.y
|
||||
);
|
||||
|
||||
// Check bounds
|
||||
if !self.storage.is_valid_coord(start) || !self.storage.is_valid_coord(goal) {
|
||||
debug!("[CachedAStar] FAILED: OutOfBounds - start or goal outside grid");
|
||||
return PathResult::failed(PathFailure::OutOfBounds, 0);
|
||||
}
|
||||
|
||||
// Check start and goal traversability using cache
|
||||
if !self.is_traversable(start) {
|
||||
debug!("[CachedAStar] FAILED: StartBlocked");
|
||||
return PathResult::failed(PathFailure::StartBlocked, 0);
|
||||
}
|
||||
if !self.is_traversable(goal) {
|
||||
debug!("[CachedAStar] FAILED: GoalBlocked");
|
||||
return PathResult::failed(PathFailure::GoalBlocked, 0);
|
||||
}
|
||||
|
||||
// A* search
|
||||
let mut open_set = BinaryHeap::new();
|
||||
let mut closed_set = HashSet::new();
|
||||
let mut came_from: HashMap<GridCoord, GridCoord> = HashMap::new();
|
||||
let mut g_scores: HashMap<GridCoord, f32> = HashMap::new();
|
||||
|
||||
let h_start = self.heuristic(start, goal);
|
||||
open_set.push(AStarNode {
|
||||
coord: start,
|
||||
g_cost: 0.0,
|
||||
f_cost: h_start,
|
||||
});
|
||||
g_scores.insert(start, 0.0);
|
||||
|
||||
let mut nodes_expanded = 0;
|
||||
|
||||
while let Some(current) = open_set.pop() {
|
||||
nodes_expanded += 1;
|
||||
|
||||
if nodes_expanded > self.config.max_iterations {
|
||||
debug!(
|
||||
"[CachedAStar] FAILED: MaxIterationsExceeded ({} nodes)",
|
||||
nodes_expanded
|
||||
);
|
||||
return PathResult::failed(PathFailure::MaxIterationsExceeded, nodes_expanded);
|
||||
}
|
||||
|
||||
// Goal reached
|
||||
if current.coord == goal {
|
||||
return self.reconstruct_path(came_from, goal, current.g_cost, nodes_expanded);
|
||||
}
|
||||
|
||||
if closed_set.contains(¤t.coord) {
|
||||
continue;
|
||||
}
|
||||
closed_set.insert(current.coord);
|
||||
|
||||
// Explore neighbors
|
||||
let neighbors = if self.config.allow_diagonal {
|
||||
current.coord.neighbors_8().to_vec()
|
||||
} else {
|
||||
current.coord.neighbors_4().to_vec()
|
||||
};
|
||||
|
||||
for (i, neighbor) in neighbors.iter().enumerate() {
|
||||
if closed_set.contains(neighbor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check traversability using cache (O(1) lookup)
|
||||
if !self.is_traversable(*neighbor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate movement cost
|
||||
let is_diagonal = self.config.allow_diagonal && i >= 4;
|
||||
let move_cost = if is_diagonal {
|
||||
self.config.diagonal_cost
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let tentative_g = g_scores[¤t.coord] + move_cost;
|
||||
|
||||
let current_g = g_scores.get(neighbor).copied().unwrap_or(f32::INFINITY);
|
||||
if tentative_g < current_g {
|
||||
came_from.insert(*neighbor, current.coord);
|
||||
g_scores.insert(*neighbor, tentative_g);
|
||||
|
||||
let h = self.heuristic(*neighbor, goal);
|
||||
open_set.push(AStarNode {
|
||||
coord: *neighbor,
|
||||
g_cost: tentative_g,
|
||||
f_cost: tentative_g + h,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
"[CachedAStar] FAILED: NoPath after expanding {} nodes",
|
||||
nodes_expanded
|
||||
);
|
||||
PathResult::failed(PathFailure::NoPath, nodes_expanded)
|
||||
}
|
||||
|
||||
/// Find a path from start to goal (world coordinates)
|
||||
pub fn find_path_world(&self, start: WorldPoint, goal: WorldPoint) -> PathResult {
|
||||
let start_grid = self.storage.world_to_grid(start);
|
||||
let goal_grid = self.storage.world_to_grid(goal);
|
||||
self.find_path(start_grid, goal_grid)
|
||||
}
|
||||
|
||||
/// Heuristic function (octile distance for 8-connected grid)
|
||||
fn heuristic(&self, from: GridCoord, to: GridCoord) -> f32 {
|
||||
let dx = (from.x - to.x).abs() as f32;
|
||||
let dy = (from.y - to.y).abs() as f32;
|
||||
|
||||
if self.config.allow_diagonal {
|
||||
let min = dx.min(dy);
|
||||
let max = dx.max(dy);
|
||||
min * self.config.diagonal_cost + (max - min)
|
||||
} else {
|
||||
dx + dy
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct the path from came_from map
|
||||
fn reconstruct_path(
|
||||
&self,
|
||||
came_from: HashMap<GridCoord, GridCoord>,
|
||||
goal: GridCoord,
|
||||
cost: f32,
|
||||
nodes_expanded: usize,
|
||||
) -> PathResult {
|
||||
let mut path_grid = Vec::new();
|
||||
let mut current = goal;
|
||||
|
||||
while let Some(&prev) = came_from.get(¤t) {
|
||||
path_grid.push(current);
|
||||
current = prev;
|
||||
}
|
||||
path_grid.push(current); // Add start
|
||||
path_grid.reverse();
|
||||
|
||||
let path_world: Vec<WorldPoint> = path_grid
|
||||
.iter()
|
||||
.map(|c| self.storage.grid_to_world(*c))
|
||||
.collect();
|
||||
|
||||
trace!(
|
||||
"[CachedAStar] SUCCESS: path length={} cells, cost={:.2}, nodes_expanded={}",
|
||||
path_grid.len(),
|
||||
cost,
|
||||
nodes_expanded
|
||||
);
|
||||
|
||||
PathResult {
|
||||
path_grid,
|
||||
path_world,
|
||||
cost,
|
||||
nodes_expanded,
|
||||
success: true,
|
||||
failure_reason: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! A* pathfinding algorithm.
|
||||
//!
|
||||
//! Implements A* search on the occupancy grid with support for:
|
||||
//! - Robot footprint collision checking
|
||||
//! - Safety margin around obstacles
|
||||
//! - 8-connected grid movement
|
||||
|
||||
mod cached;
|
||||
mod planner;
|
||||
mod types;
|
||||
|
||||
pub use cached::CachedAStarPlanner;
|
||||
pub use planner::AStarPlanner;
|
||||
pub use types::{AStarConfig, PathFailure, PathResult};
|
||||
|
||||
use crate::core::WorldPoint;
|
||||
use crate::grid::GridStorage;
|
||||
|
||||
/// Quick path finding with default configuration
|
||||
pub fn find_path(storage: &GridStorage, start: WorldPoint, goal: WorldPoint) -> PathResult {
|
||||
let planner = AStarPlanner::with_defaults(storage);
|
||||
planner.find_path_world(start, goal)
|
||||
}
|
||||
|
||||
/// Check if a path exists (faster than full path computation)
|
||||
pub fn path_exists(storage: &GridStorage, start: WorldPoint, goal: WorldPoint) -> bool {
|
||||
find_path(storage, start, goal).success
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::{CellType, GridCoord};
|
||||
use crate::query::RobotFootprint;
|
||||
|
||||
fn create_test_storage() -> GridStorage {
|
||||
GridStorage::centered(50, 50, 0.1) // 5m x 5m at 10cm resolution
|
||||
}
|
||||
|
||||
fn fill_floor(storage: &mut GridStorage) {
|
||||
for x in 0..50 {
|
||||
for y in 0..50 {
|
||||
storage.set_type(GridCoord::new(x, y), CellType::Floor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_path() {
|
||||
let mut storage = create_test_storage();
|
||||
fill_floor(&mut storage);
|
||||
|
||||
// Small footprint for this test
|
||||
let config = AStarConfig::with_footprint(RobotFootprint::new(0.05, 0.01));
|
||||
let planner = AStarPlanner::new(&storage, config);
|
||||
|
||||
let start = GridCoord::new(10, 25);
|
||||
let goal = GridCoord::new(40, 25);
|
||||
|
||||
let result = planner.find_path(start, goal);
|
||||
|
||||
assert!(result.success);
|
||||
assert!(!result.path_grid.is_empty());
|
||||
assert_eq!(result.path_grid[0], start);
|
||||
assert_eq!(*result.path_grid.last().unwrap(), goal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_around_obstacle() {
|
||||
let mut storage = create_test_storage();
|
||||
fill_floor(&mut storage);
|
||||
|
||||
// Add a wall across the middle
|
||||
for y in 15..35 {
|
||||
storage.set_type_with_priority(GridCoord::new(25, y), CellType::Wall);
|
||||
}
|
||||
|
||||
let config = AStarConfig::with_footprint(RobotFootprint::new(0.05, 0.01));
|
||||
let planner = AStarPlanner::new(&storage, config);
|
||||
|
||||
let start = GridCoord::new(10, 25);
|
||||
let goal = GridCoord::new(40, 25);
|
||||
|
||||
let result = planner.find_path(start, goal);
|
||||
|
||||
assert!(result.success);
|
||||
// Path should go around the wall
|
||||
assert!(result.path_grid.len() > 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_path() {
|
||||
let mut storage = create_test_storage();
|
||||
fill_floor(&mut storage);
|
||||
|
||||
// Add a complete wall barrier
|
||||
for y in 0..50 {
|
||||
storage.set_type_with_priority(GridCoord::new(25, y), CellType::Wall);
|
||||
}
|
||||
|
||||
let config = AStarConfig::with_footprint(RobotFootprint::new(0.05, 0.01));
|
||||
let planner = AStarPlanner::new(&storage, config);
|
||||
|
||||
let start = GridCoord::new(10, 25);
|
||||
let goal = GridCoord::new(40, 25);
|
||||
|
||||
let result = planner.find_path(start, goal);
|
||||
|
||||
assert!(!result.success);
|
||||
assert_eq!(result.failure_reason, Some(PathFailure::NoPath));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_start_blocked() {
|
||||
let mut storage = create_test_storage();
|
||||
fill_floor(&mut storage);
|
||||
|
||||
// Block start position
|
||||
storage.set_type_with_priority(GridCoord::new(10, 25), CellType::Wall);
|
||||
|
||||
let config = AStarConfig::with_footprint(RobotFootprint::new(0.05, 0.01));
|
||||
let planner = AStarPlanner::new(&storage, config);
|
||||
|
||||
let start = GridCoord::new(10, 25);
|
||||
let goal = GridCoord::new(40, 25);
|
||||
|
||||
let result = planner.find_path(start, goal);
|
||||
|
||||
assert!(!result.success);
|
||||
assert_eq!(result.failure_reason, Some(PathFailure::StartBlocked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_goal_blocked() {
|
||||
let mut storage = create_test_storage();
|
||||
fill_floor(&mut storage);
|
||||
|
||||
// Block goal position
|
||||
storage.set_type_with_priority(GridCoord::new(40, 25), CellType::Wall);
|
||||
|
||||
let config = AStarConfig::with_footprint(RobotFootprint::new(0.05, 0.01));
|
||||
let planner = AStarPlanner::new(&storage, config);
|
||||
|
||||
let start = GridCoord::new(10, 25);
|
||||
let goal = GridCoord::new(40, 25);
|
||||
|
||||
let result = planner.find_path(start, goal);
|
||||
|
||||
assert!(!result.success);
|
||||
assert_eq!(result.failure_reason, Some(PathFailure::GoalBlocked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diagonal_path() {
|
||||
let mut storage = create_test_storage();
|
||||
fill_floor(&mut storage);
|
||||
|
||||
let config = AStarConfig {
|
||||
footprint: RobotFootprint::new(0.05, 0.01),
|
||||
allow_diagonal: true,
|
||||
..Default::default()
|
||||
};
|
||||
let planner = AStarPlanner::new(&storage, config);
|
||||
|
||||
let start = GridCoord::new(10, 10);
|
||||
let goal = GridCoord::new(40, 40);
|
||||
|
||||
let result = planner.find_path(start, goal);
|
||||
|
||||
assert!(result.success);
|
||||
// Diagonal path should be shorter than manhattan
|
||||
// For a 30x30 diagonal, manhattan would be 60, octile should be ~42
|
||||
assert!(result.path_grid.len() < 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_4_connected_path() {
|
||||
let mut storage = create_test_storage();
|
||||
fill_floor(&mut storage);
|
||||
|
||||
let config = AStarConfig {
|
||||
footprint: RobotFootprint::new(0.05, 0.01),
|
||||
allow_diagonal: false,
|
||||
..Default::default()
|
||||
};
|
||||
let planner = AStarPlanner::new(&storage, config);
|
||||
|
||||
let start = GridCoord::new(10, 10);
|
||||
let goal = GridCoord::new(40, 40);
|
||||
|
||||
let result = planner.find_path(start, goal);
|
||||
|
||||
assert!(result.success);
|
||||
// 4-connected path should follow manhattan distance
|
||||
// Path length should be approximately dx + dy = 60
|
||||
assert!(result.path_grid.len() >= 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_world_coordinates() {
|
||||
let mut storage = create_test_storage();
|
||||
fill_floor(&mut storage);
|
||||
|
||||
let config = AStarConfig::with_footprint(RobotFootprint::new(0.05, 0.01));
|
||||
let planner = AStarPlanner::new(&storage, config);
|
||||
|
||||
let start = WorldPoint::new(-1.0, 0.0);
|
||||
let goal = WorldPoint::new(1.0, 0.0);
|
||||
|
||||
let result = planner.find_path_world(start, goal);
|
||||
|
||||
assert!(result.success);
|
||||
assert!(result.path_world.len() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_length_meters() {
|
||||
let mut storage = create_test_storage();
|
||||
fill_floor(&mut storage);
|
||||
|
||||
let config = AStarConfig::with_footprint(RobotFootprint::new(0.05, 0.01));
|
||||
let planner = AStarPlanner::new(&storage, config);
|
||||
|
||||
// Path of 2 meters (20 cells at 10cm resolution)
|
||||
let start = WorldPoint::new(-1.0, 0.0);
|
||||
let goal = WorldPoint::new(1.0, 0.0);
|
||||
|
||||
let result = planner.find_path_world(start, goal);
|
||||
|
||||
assert!(result.success);
|
||||
// Path length should be approximately 2.0 meters
|
||||
let length = result.length_meters();
|
||||
assert!(length > 1.5 && length < 2.5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! A* planner implementation.
|
||||
|
||||
use crate::core::{GridCoord, WorldPoint};
|
||||
use crate::grid::GridStorage;
|
||||
use crate::query::TraversabilityChecker;
|
||||
use log::{debug, trace};
|
||||
use std::collections::{BinaryHeap, HashMap, HashSet};
|
||||
|
||||
use super::types::{AStarConfig, AStarNode, PathFailure, PathResult};
|
||||
|
||||
/// A* pathfinder
|
||||
pub struct AStarPlanner<'a> {
|
||||
storage: &'a GridStorage,
|
||||
config: AStarConfig,
|
||||
}
|
||||
|
||||
impl<'a> AStarPlanner<'a> {
|
||||
/// Create a new A* planner
|
||||
pub fn new(storage: &'a GridStorage, config: AStarConfig) -> Self {
|
||||
Self { storage, config }
|
||||
}
|
||||
|
||||
/// Create with default configuration
|
||||
pub fn with_defaults(storage: &'a GridStorage) -> Self {
|
||||
Self::new(storage, AStarConfig::default())
|
||||
}
|
||||
|
||||
/// Find a path from start to goal (grid coordinates)
|
||||
pub fn find_path(&self, start: GridCoord, goal: GridCoord) -> PathResult {
|
||||
trace!(
|
||||
"[AStar] find_path: start=({},{}) goal=({},{})",
|
||||
start.x, start.y, goal.x, goal.y
|
||||
);
|
||||
|
||||
// Check bounds
|
||||
if !self.storage.is_valid_coord(start) || !self.storage.is_valid_coord(goal) {
|
||||
debug!("[AStar] FAILED: OutOfBounds - start or goal outside grid");
|
||||
return PathResult::failed(PathFailure::OutOfBounds, 0);
|
||||
}
|
||||
|
||||
// Check start and goal traversability using footprint
|
||||
let checker = TraversabilityChecker::new(self.storage, self.config.footprint.clone());
|
||||
|
||||
let start_world = self.storage.grid_to_world(start);
|
||||
let goal_world = self.storage.grid_to_world(goal);
|
||||
|
||||
if !checker.is_position_safe_mode(start_world, self.config.exploration_mode) {
|
||||
debug!(
|
||||
"[AStar] FAILED: StartBlocked at ({:.2},{:.2}), exploration_mode={}",
|
||||
start_world.x, start_world.y, self.config.exploration_mode
|
||||
);
|
||||
return PathResult::failed(PathFailure::StartBlocked, 0);
|
||||
}
|
||||
if !checker.is_position_safe_mode(goal_world, self.config.exploration_mode) {
|
||||
debug!(
|
||||
"[AStar] FAILED: GoalBlocked at ({:.2},{:.2}), exploration_mode={}",
|
||||
goal_world.x, goal_world.y, self.config.exploration_mode
|
||||
);
|
||||
return PathResult::failed(PathFailure::GoalBlocked, 0);
|
||||
}
|
||||
|
||||
// A* search
|
||||
let mut open_set = BinaryHeap::new();
|
||||
let mut closed_set = HashSet::new();
|
||||
let mut came_from: HashMap<GridCoord, GridCoord> = HashMap::new();
|
||||
let mut g_scores: HashMap<GridCoord, f32> = HashMap::new();
|
||||
|
||||
let h_start = self.heuristic(start, goal);
|
||||
open_set.push(AStarNode {
|
||||
coord: start,
|
||||
g_cost: 0.0,
|
||||
f_cost: h_start,
|
||||
});
|
||||
g_scores.insert(start, 0.0);
|
||||
|
||||
let mut nodes_expanded = 0;
|
||||
|
||||
while let Some(current) = open_set.pop() {
|
||||
nodes_expanded += 1;
|
||||
|
||||
if nodes_expanded > self.config.max_iterations {
|
||||
debug!(
|
||||
"[AStar] FAILED: MaxIterationsExceeded ({} nodes)",
|
||||
nodes_expanded
|
||||
);
|
||||
return PathResult::failed(PathFailure::MaxIterationsExceeded, nodes_expanded);
|
||||
}
|
||||
|
||||
// Goal reached
|
||||
if current.coord == goal {
|
||||
return self.reconstruct_path(came_from, goal, current.g_cost, nodes_expanded);
|
||||
}
|
||||
|
||||
if closed_set.contains(¤t.coord) {
|
||||
continue;
|
||||
}
|
||||
closed_set.insert(current.coord);
|
||||
|
||||
// Explore neighbors
|
||||
let neighbors = if self.config.allow_diagonal {
|
||||
current.coord.neighbors_8().to_vec()
|
||||
} else {
|
||||
current.coord.neighbors_4().to_vec()
|
||||
};
|
||||
|
||||
for (i, neighbor) in neighbors.iter().enumerate() {
|
||||
if closed_set.contains(neighbor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !self.storage.is_valid_coord(*neighbor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check traversability with footprint
|
||||
let neighbor_world = self.storage.grid_to_world(*neighbor);
|
||||
if !checker.is_position_safe_mode(neighbor_world, self.config.exploration_mode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate movement cost
|
||||
let is_diagonal = self.config.allow_diagonal && i >= 4;
|
||||
let move_cost = if is_diagonal {
|
||||
self.config.diagonal_cost
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
// Add clearance cost if enabled
|
||||
let clearance_cost = if self.config.clearance_weight > 0.0 {
|
||||
let clearance = checker.clearance(neighbor_world, 1.0);
|
||||
// Penalize positions close to obstacles
|
||||
self.config.clearance_weight / (clearance + 0.1)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let tentative_g = g_scores[¤t.coord] + move_cost + clearance_cost;
|
||||
|
||||
let current_g = g_scores.get(neighbor).copied().unwrap_or(f32::INFINITY);
|
||||
if tentative_g < current_g {
|
||||
came_from.insert(*neighbor, current.coord);
|
||||
g_scores.insert(*neighbor, tentative_g);
|
||||
|
||||
let h = self.heuristic(*neighbor, goal);
|
||||
open_set.push(AStarNode {
|
||||
coord: *neighbor,
|
||||
g_cost: tentative_g,
|
||||
f_cost: tentative_g + h,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
"[AStar] FAILED: NoPath after expanding {} nodes",
|
||||
nodes_expanded
|
||||
);
|
||||
PathResult::failed(PathFailure::NoPath, nodes_expanded)
|
||||
}
|
||||
|
||||
/// Find a path from start to goal (world coordinates)
|
||||
pub fn find_path_world(&self, start: WorldPoint, goal: WorldPoint) -> PathResult {
|
||||
let start_grid = self.storage.world_to_grid(start);
|
||||
let goal_grid = self.storage.world_to_grid(goal);
|
||||
self.find_path(start_grid, goal_grid)
|
||||
}
|
||||
|
||||
/// Heuristic function (octile distance for 8-connected grid)
|
||||
fn heuristic(&self, from: GridCoord, to: GridCoord) -> f32 {
|
||||
let dx = (from.x - to.x).abs() as f32;
|
||||
let dy = (from.y - to.y).abs() as f32;
|
||||
|
||||
if self.config.allow_diagonal {
|
||||
// Octile distance
|
||||
let min = dx.min(dy);
|
||||
let max = dx.max(dy);
|
||||
min * self.config.diagonal_cost + (max - min)
|
||||
} else {
|
||||
// Manhattan distance
|
||||
dx + dy
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct the path from came_from map
|
||||
fn reconstruct_path(
|
||||
&self,
|
||||
came_from: HashMap<GridCoord, GridCoord>,
|
||||
goal: GridCoord,
|
||||
cost: f32,
|
||||
nodes_expanded: usize,
|
||||
) -> PathResult {
|
||||
let mut path_grid = Vec::new();
|
||||
let mut current = goal;
|
||||
|
||||
while let Some(&prev) = came_from.get(¤t) {
|
||||
path_grid.push(current);
|
||||
current = prev;
|
||||
}
|
||||
path_grid.push(current); // Add start
|
||||
path_grid.reverse();
|
||||
|
||||
// Convert to world coordinates
|
||||
let path_world: Vec<WorldPoint> = path_grid
|
||||
.iter()
|
||||
.map(|c| self.storage.grid_to_world(*c))
|
||||
.collect();
|
||||
|
||||
trace!(
|
||||
"[AStar] SUCCESS: path length={} cells, cost={:.2}, nodes_expanded={}",
|
||||
path_grid.len(),
|
||||
cost,
|
||||
nodes_expanded
|
||||
);
|
||||
|
||||
PathResult {
|
||||
path_grid,
|
||||
path_world,
|
||||
cost,
|
||||
nodes_expanded,
|
||||
success: true,
|
||||
failure_reason: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! A* pathfinding types.
|
||||
|
||||
use crate::core::{GridCoord, WorldPoint};
|
||||
use crate::query::RobotFootprint;
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// A node in the A* search
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct AStarNode {
|
||||
pub coord: GridCoord,
|
||||
pub g_cost: f32, // Cost from start
|
||||
pub f_cost: f32, // g_cost + heuristic
|
||||
}
|
||||
|
||||
impl Eq for AStarNode {}
|
||||
|
||||
impl PartialEq for AStarNode {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.coord == other.coord
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for AStarNode {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
// Reverse ordering for min-heap behavior
|
||||
other
|
||||
.f_cost
|
||||
.partial_cmp(&self.f_cost)
|
||||
.unwrap_or(Ordering::Equal)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for AStarNode {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
/// A* pathfinding configuration
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AStarConfig {
|
||||
/// Robot footprint for collision checking
|
||||
pub footprint: RobotFootprint,
|
||||
/// Allow diagonal movement (8-connected vs 4-connected)
|
||||
pub allow_diagonal: bool,
|
||||
/// Diagonal movement cost multiplier (sqrt(2) ≈ 1.414)
|
||||
pub diagonal_cost: f32,
|
||||
/// Maximum number of nodes to expand before giving up
|
||||
pub max_iterations: usize,
|
||||
/// Prefer paths with more clearance from obstacles
|
||||
pub clearance_weight: f32,
|
||||
/// Exploration mode: allow Unknown cells in footprint
|
||||
/// (assumes unknown areas are likely free)
|
||||
pub exploration_mode: bool,
|
||||
}
|
||||
|
||||
impl Default for AStarConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
footprint: RobotFootprint::default(),
|
||||
allow_diagonal: true,
|
||||
diagonal_cost: std::f32::consts::SQRT_2,
|
||||
max_iterations: 100_000,
|
||||
clearance_weight: 0.0, // Disabled by default for performance
|
||||
exploration_mode: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AStarConfig {
|
||||
/// Create with custom robot footprint
|
||||
pub fn with_footprint(footprint: RobotFootprint) -> Self {
|
||||
Self {
|
||||
footprint,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable clearance-weighted pathfinding
|
||||
pub fn with_clearance_weight(mut self, weight: f32) -> Self {
|
||||
self.clearance_weight = weight;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of A* pathfinding
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PathResult {
|
||||
/// Path as grid coordinates (empty if no path found)
|
||||
pub path_grid: Vec<GridCoord>,
|
||||
/// Path as world coordinates
|
||||
pub path_world: Vec<WorldPoint>,
|
||||
/// Total path cost
|
||||
pub cost: f32,
|
||||
/// Number of nodes expanded during search
|
||||
pub nodes_expanded: usize,
|
||||
/// Whether a path was found
|
||||
pub success: bool,
|
||||
/// Reason for failure (if any)
|
||||
pub failure_reason: Option<PathFailure>,
|
||||
}
|
||||
|
||||
impl PathResult {
|
||||
/// Create a failed result
|
||||
pub(super) fn failed(reason: PathFailure, nodes_expanded: usize) -> Self {
|
||||
Self {
|
||||
path_grid: Vec::new(),
|
||||
path_world: Vec::new(),
|
||||
cost: f32::INFINITY,
|
||||
nodes_expanded,
|
||||
success: false,
|
||||
failure_reason: Some(reason),
|
||||
}
|
||||
}
|
||||
|
||||
/// Path length in cells
|
||||
pub fn length_cells(&self) -> usize {
|
||||
self.path_grid.len()
|
||||
}
|
||||
|
||||
/// Path length in meters (approximate)
|
||||
pub fn length_meters(&self) -> f32 {
|
||||
if self.path_world.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut total = 0.0;
|
||||
for i in 1..self.path_world.len() {
|
||||
total += self.path_world[i - 1].distance(&self.path_world[i]);
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
|
||||
/// Reason for path failure
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PathFailure {
|
||||
/// Start position is not traversable
|
||||
StartBlocked,
|
||||
/// Goal position is not traversable
|
||||
GoalBlocked,
|
||||
/// No path exists between start and goal
|
||||
NoPath,
|
||||
/// Maximum iterations exceeded
|
||||
MaxIterationsExceeded,
|
||||
/// Start or goal is out of bounds
|
||||
OutOfBounds,
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
//! Traversability checker implementation.
|
||||
|
||||
use std::simd::{cmp::SimdPartialOrd, u8x16};
|
||||
|
||||
use crate::core::{CellType, GridCoord, WorldPoint};
|
||||
use crate::grid::GridStorage;
|
||||
|
||||
use super::footprint::RobotFootprint;
|
||||
use super::path::PathCheckResult;
|
||||
|
||||
/// Traversability checker for path planning and navigation.
|
||||
pub struct TraversabilityChecker<'a> {
|
||||
storage: &'a GridStorage,
|
||||
footprint: RobotFootprint,
|
||||
}
|
||||
|
||||
impl<'a> TraversabilityChecker<'a> {
|
||||
/// Create a new traversability checker.
|
||||
pub fn new(storage: &'a GridStorage, footprint: RobotFootprint) -> Self {
|
||||
Self { storage, footprint }
|
||||
}
|
||||
|
||||
/// Create with default robot footprint.
|
||||
pub fn with_default_footprint(storage: &'a GridStorage) -> Self {
|
||||
Self::new(storage, RobotFootprint::default())
|
||||
}
|
||||
|
||||
/// Get the robot footprint.
|
||||
pub fn footprint(&self) -> &RobotFootprint {
|
||||
&self.footprint
|
||||
}
|
||||
|
||||
/// Get the storage.
|
||||
pub fn storage(&self) -> &GridStorage {
|
||||
self.storage
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Single cell traversability
|
||||
// =========================================================================
|
||||
|
||||
/// Check if a single cell is traversable.
|
||||
pub fn is_cell_traversable(&self, coord: GridCoord) -> bool {
|
||||
if !self.storage.is_valid_coord(coord) {
|
||||
return false;
|
||||
}
|
||||
self.storage.get_type(coord).is_traversable()
|
||||
}
|
||||
|
||||
/// Check if a world point is traversable (single cell).
|
||||
pub fn is_point_traversable(&self, point: WorldPoint) -> bool {
|
||||
let coord = self.storage.world_to_grid(point);
|
||||
self.is_cell_traversable(coord)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Position safety (considering footprint)
|
||||
// =========================================================================
|
||||
|
||||
/// Check if a position is safe for the robot (considering footprint).
|
||||
pub fn is_position_safe(&self, position: WorldPoint) -> bool {
|
||||
self.is_position_safe_mode(position, false)
|
||||
}
|
||||
|
||||
/// Check if a position is safe, with option to allow Unknown cells (SIMD-optimized).
|
||||
///
|
||||
/// In exploration mode, Unknown cells are allowed in the footprint
|
||||
/// (they're assumed to be likely free). Only actual obstacles block.
|
||||
///
|
||||
/// Uses SIMD to quickly check the bounding box for obstacles before
|
||||
/// performing the circular footprint check.
|
||||
pub fn is_position_safe_mode(&self, position: WorldPoint, allow_unknown: bool) -> bool {
|
||||
let center = self.storage.world_to_grid(position);
|
||||
let resolution = self.storage.resolution();
|
||||
let cells_radius = (self.footprint.total_radius() / resolution).ceil() as i32;
|
||||
|
||||
// Fast path: SIMD check for obstacles in bounding box
|
||||
if !self.is_rect_obstacle_free(center, cells_radius, cells_radius) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If allow_unknown is true, we're done (no obstacles found via SIMD)
|
||||
if allow_unknown {
|
||||
// Still need to check bounds validity
|
||||
return self.is_footprint_in_bounds(center, cells_radius, position);
|
||||
}
|
||||
|
||||
// Strict mode: Need to check for Unknown cells in circular footprint
|
||||
self.is_footprint_traversable(center, cells_radius, position)
|
||||
}
|
||||
|
||||
/// Check if a position is known (all cells within footprint are known).
|
||||
pub fn is_position_known(&self, position: WorldPoint) -> bool {
|
||||
let center = self.storage.world_to_grid(position);
|
||||
let resolution = self.storage.resolution();
|
||||
let cells_radius = (self.footprint.total_radius() / resolution).ceil() as i32;
|
||||
|
||||
for dy in -cells_radius..=cells_radius {
|
||||
for dx in -cells_radius..=cells_radius {
|
||||
let coord = center + GridCoord::new(dx, dy);
|
||||
|
||||
if !self.storage.is_valid_coord(coord) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let cell_world = self.storage.grid_to_world(coord);
|
||||
let dist = cell_world.distance(&position);
|
||||
|
||||
if dist <= self.footprint.total_radius() && !self.storage.get_type(coord).is_known()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SIMD-optimized obstacle checking
|
||||
// =========================================================================
|
||||
|
||||
/// Check if a rectangular region contains any obstacle cells (SIMD-optimized).
|
||||
///
|
||||
/// Obstacles are Wall, Cliff, or Bump (cell_type >= 2).
|
||||
/// Returns true if no obstacles found.
|
||||
#[inline]
|
||||
pub(super) fn is_rect_obstacle_free(
|
||||
&self,
|
||||
center: GridCoord,
|
||||
half_width: i32,
|
||||
half_height: i32,
|
||||
) -> bool {
|
||||
let width = self.storage.width() as i32;
|
||||
let height = self.storage.height() as i32;
|
||||
|
||||
let min_x = (center.x - half_width).max(0) as usize;
|
||||
let max_x = ((center.x + half_width).min(width - 1) as usize).min(self.storage.width() - 1);
|
||||
let min_y = (center.y - half_height).max(0) as usize;
|
||||
let max_y =
|
||||
((center.y + half_height).min(height - 1) as usize).min(self.storage.height() - 1);
|
||||
|
||||
if min_x > max_x || min_y > max_y {
|
||||
return false; // Out of bounds
|
||||
}
|
||||
|
||||
let cell_types = self.storage.cell_types_raw();
|
||||
let storage_width = self.storage.width();
|
||||
|
||||
// Wall, Cliff, Bump are >= 2 (Floor=1, Unknown=0)
|
||||
let wall_val = CellType::Wall as u8;
|
||||
let wall_vec = u8x16::splat(wall_val);
|
||||
|
||||
for y in min_y..=max_y {
|
||||
let row_start = y * storage_width + min_x;
|
||||
let row_end = y * storage_width + max_x + 1;
|
||||
let row_slice = &cell_types[row_start..row_end];
|
||||
|
||||
// Process 16 cells at a time
|
||||
let chunks = row_slice.chunks_exact(16);
|
||||
let remainder = chunks.remainder();
|
||||
|
||||
for chunk in chunks {
|
||||
let data = u8x16::from_slice(chunk);
|
||||
// Check if any cell >= Wall (2)
|
||||
let is_obstacle = data.simd_ge(wall_vec);
|
||||
if is_obstacle.to_bitmask() != 0 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Scalar remainder
|
||||
for &cell_type in remainder {
|
||||
if cell_type >= wall_val {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Footprint checking helpers
|
||||
// =========================================================================
|
||||
|
||||
/// Check if all cells in the circular footprint are within bounds.
|
||||
#[inline]
|
||||
fn is_footprint_in_bounds(
|
||||
&self,
|
||||
center: GridCoord,
|
||||
cells_radius: i32,
|
||||
position: WorldPoint,
|
||||
) -> bool {
|
||||
for dy in -cells_radius..=cells_radius {
|
||||
for dx in -cells_radius..=cells_radius {
|
||||
let coord = center + GridCoord::new(dx, dy);
|
||||
|
||||
if !self.storage.is_valid_coord(coord) {
|
||||
// Check if this cell is within the circular footprint
|
||||
let cell_world = self.storage.grid_to_world(coord);
|
||||
let dist = cell_world.distance(&position);
|
||||
if dist <= self.footprint.total_radius() {
|
||||
return false; // Out of bounds within footprint
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Check if all cells in the circular footprint are traversable (Floor).
|
||||
#[inline]
|
||||
fn is_footprint_traversable(
|
||||
&self,
|
||||
center: GridCoord,
|
||||
cells_radius: i32,
|
||||
position: WorldPoint,
|
||||
) -> bool {
|
||||
for dy in -cells_radius..=cells_radius {
|
||||
for dx in -cells_radius..=cells_radius {
|
||||
let coord = center + GridCoord::new(dx, dy);
|
||||
|
||||
if !self.storage.is_valid_coord(coord) {
|
||||
// Check if this cell is within the circular footprint
|
||||
let cell_world = self.storage.grid_to_world(coord);
|
||||
let dist = cell_world.distance(&position);
|
||||
if dist <= self.footprint.total_radius() {
|
||||
return false; // Out of bounds within footprint
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if within circular footprint
|
||||
let cell_world = self.storage.grid_to_world(coord);
|
||||
let dist = cell_world.distance(&position);
|
||||
|
||||
if dist <= self.footprint.total_radius() {
|
||||
let cell_type = self.storage.get_type(coord);
|
||||
if !cell_type.is_traversable() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Path checking
|
||||
// =========================================================================
|
||||
|
||||
/// Check if a straight-line path is traversable.
|
||||
/// Returns the furthest traversable point and whether the full path is clear.
|
||||
pub fn check_path(
|
||||
&self,
|
||||
start: WorldPoint,
|
||||
end: WorldPoint,
|
||||
step_size: Option<f32>,
|
||||
) -> PathCheckResult {
|
||||
let step = step_size.unwrap_or(self.storage.resolution() * 0.5);
|
||||
let total_dist = start.distance(&end);
|
||||
|
||||
if total_dist < step {
|
||||
// Very short path - just check endpoints
|
||||
let start_safe = self.is_position_safe(start);
|
||||
let end_safe = self.is_position_safe(end);
|
||||
|
||||
return PathCheckResult {
|
||||
is_clear: start_safe && end_safe,
|
||||
blocked_at: if !start_safe {
|
||||
Some(start)
|
||||
} else if !end_safe {
|
||||
Some(end)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
furthest_safe: if start_safe { Some(end) } else { None },
|
||||
distance_traversable: if start_safe && end_safe {
|
||||
total_dist
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let dir_x = (end.x - start.x) / total_dist;
|
||||
let dir_y = (end.y - start.y) / total_dist;
|
||||
|
||||
let mut furthest_safe = start;
|
||||
let mut distance_traversable = 0.0;
|
||||
|
||||
// Check start position first
|
||||
if !self.is_position_safe(start) {
|
||||
return PathCheckResult {
|
||||
is_clear: false,
|
||||
blocked_at: Some(start),
|
||||
furthest_safe: None,
|
||||
distance_traversable: 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
let mut dist = step;
|
||||
while dist < total_dist {
|
||||
let point = WorldPoint::new(start.x + dir_x * dist, start.y + dir_y * dist);
|
||||
|
||||
if self.is_position_safe(point) {
|
||||
furthest_safe = point;
|
||||
distance_traversable = dist;
|
||||
} else {
|
||||
return PathCheckResult {
|
||||
is_clear: false,
|
||||
blocked_at: Some(point),
|
||||
furthest_safe: Some(furthest_safe),
|
||||
distance_traversable,
|
||||
};
|
||||
}
|
||||
|
||||
dist += step;
|
||||
}
|
||||
|
||||
// Check endpoint
|
||||
if self.is_position_safe(end) {
|
||||
PathCheckResult {
|
||||
is_clear: true,
|
||||
blocked_at: None,
|
||||
furthest_safe: Some(end),
|
||||
distance_traversable: total_dist,
|
||||
}
|
||||
} else {
|
||||
PathCheckResult {
|
||||
is_clear: false,
|
||||
blocked_at: Some(end),
|
||||
furthest_safe: Some(furthest_safe),
|
||||
distance_traversable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if movement from current position in a direction is safe.
|
||||
pub fn check_direction(
|
||||
&self,
|
||||
position: WorldPoint,
|
||||
angle: f32,
|
||||
distance: f32,
|
||||
) -> PathCheckResult {
|
||||
let end = WorldPoint::new(
|
||||
position.x + distance * angle.cos(),
|
||||
position.y + distance * angle.sin(),
|
||||
);
|
||||
self.check_path(position, end, None)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Obstacle queries
|
||||
// =========================================================================
|
||||
|
||||
/// Find the nearest obstacle from a position.
|
||||
pub fn nearest_obstacle(&self, position: WorldPoint, max_distance: f32) -> Option<WorldPoint> {
|
||||
let center = self.storage.world_to_grid(position);
|
||||
let resolution = self.storage.resolution();
|
||||
let max_cells = (max_distance / resolution).ceil() as i32;
|
||||
|
||||
let mut nearest: Option<(WorldPoint, f32)> = None;
|
||||
|
||||
for dy in -max_cells..=max_cells {
|
||||
for dx in -max_cells..=max_cells {
|
||||
let coord = center + GridCoord::new(dx, dy);
|
||||
|
||||
if !self.storage.is_valid_coord(coord) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cell_type = self.storage.get_type(coord);
|
||||
if cell_type.is_obstacle() {
|
||||
let obstacle_pos = self.storage.grid_to_world(coord);
|
||||
let dist = position.distance(&obstacle_pos);
|
||||
|
||||
if dist <= max_distance {
|
||||
match nearest {
|
||||
None => nearest = Some((obstacle_pos, dist)),
|
||||
Some((_, d)) if dist < d => nearest = Some((obstacle_pos, dist)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nearest.map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// Get clearance (distance to nearest obstacle) at a position.
|
||||
pub fn clearance(&self, position: WorldPoint, max_distance: f32) -> f32 {
|
||||
match self.nearest_obstacle(position, max_distance) {
|
||||
Some(obstacle) => position.distance(&obstacle),
|
||||
None => max_distance,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Robot footprint definition for collision checking.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Robot footprint for collision checking.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RobotFootprint {
|
||||
/// Robot radius in meters.
|
||||
pub radius: f32,
|
||||
/// Safety margin in meters.
|
||||
pub safety_margin: f32,
|
||||
}
|
||||
|
||||
impl Default for RobotFootprint {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
radius: 0.17, // CRL-200S robot radius
|
||||
safety_margin: 0.05, // 5cm safety margin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RobotFootprint {
|
||||
/// Create a new robot footprint.
|
||||
pub fn new(radius: f32, safety_margin: f32) -> Self {
|
||||
Self {
|
||||
radius,
|
||||
safety_margin,
|
||||
}
|
||||
}
|
||||
|
||||
/// Total radius including safety margin.
|
||||
pub fn total_radius(&self) -> f32 {
|
||||
self.radius + self.safety_margin
|
||||
}
|
||||
}
|
||||
+34
-437
@@ -2,427 +2,24 @@
|
||||
//!
|
||||
//! Provides methods to check if positions and paths are safe for robot traversal.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::simd::{cmp::SimdPartialOrd, u8x16};
|
||||
mod checker;
|
||||
mod footprint;
|
||||
mod path;
|
||||
|
||||
use crate::core::{CellType, GridCoord, WorldPoint};
|
||||
pub use checker::TraversabilityChecker;
|
||||
pub use footprint::RobotFootprint;
|
||||
pub use path::PathCheckResult;
|
||||
|
||||
use crate::core::WorldPoint;
|
||||
use crate::grid::GridStorage;
|
||||
|
||||
/// Robot footprint for collision checking
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RobotFootprint {
|
||||
/// Robot radius in meters
|
||||
pub radius: f32,
|
||||
/// Safety margin in meters
|
||||
pub safety_margin: f32,
|
||||
}
|
||||
|
||||
impl Default for RobotFootprint {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
radius: 0.17, // CRL-200S robot radius
|
||||
safety_margin: 0.05, // 5cm safety margin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RobotFootprint {
|
||||
/// Create a new robot footprint
|
||||
pub fn new(radius: f32, safety_margin: f32) -> Self {
|
||||
Self {
|
||||
radius,
|
||||
safety_margin,
|
||||
}
|
||||
}
|
||||
|
||||
/// Total radius including safety margin
|
||||
pub fn total_radius(&self) -> f32 {
|
||||
self.radius + self.safety_margin
|
||||
}
|
||||
}
|
||||
|
||||
/// Traversability checker
|
||||
pub struct TraversabilityChecker<'a> {
|
||||
storage: &'a GridStorage,
|
||||
footprint: RobotFootprint,
|
||||
}
|
||||
|
||||
impl<'a> TraversabilityChecker<'a> {
|
||||
/// Create a new traversability checker
|
||||
pub fn new(storage: &'a GridStorage, footprint: RobotFootprint) -> Self {
|
||||
Self { storage, footprint }
|
||||
}
|
||||
|
||||
/// Create with default robot footprint
|
||||
pub fn with_default_footprint(storage: &'a GridStorage) -> Self {
|
||||
Self::new(storage, RobotFootprint::default())
|
||||
}
|
||||
|
||||
/// Check if a single cell is traversable
|
||||
pub fn is_cell_traversable(&self, coord: GridCoord) -> bool {
|
||||
if !self.storage.is_valid_coord(coord) {
|
||||
return false;
|
||||
}
|
||||
self.storage.get_type(coord).is_traversable()
|
||||
}
|
||||
|
||||
/// Check if a world point is traversable (single cell)
|
||||
pub fn is_point_traversable(&self, point: WorldPoint) -> bool {
|
||||
let coord = self.storage.world_to_grid(point);
|
||||
self.is_cell_traversable(coord)
|
||||
}
|
||||
|
||||
/// Check if a position is safe for the robot (considering footprint)
|
||||
pub fn is_position_safe(&self, position: WorldPoint) -> bool {
|
||||
self.is_position_safe_mode(position, false)
|
||||
}
|
||||
|
||||
/// Check if a position is safe, with option to allow Unknown cells (SIMD-optimized).
|
||||
///
|
||||
/// In exploration mode, Unknown cells are allowed in the footprint
|
||||
/// (they're assumed to be likely free). Only actual obstacles block.
|
||||
///
|
||||
/// Uses SIMD to quickly check the bounding box for obstacles before
|
||||
/// performing the circular footprint check.
|
||||
pub fn is_position_safe_mode(&self, position: WorldPoint, allow_unknown: bool) -> bool {
|
||||
let center = self.storage.world_to_grid(position);
|
||||
let resolution = self.storage.resolution();
|
||||
let cells_radius = (self.footprint.total_radius() / resolution).ceil() as i32;
|
||||
|
||||
// Fast path: SIMD check for obstacles in bounding box
|
||||
if !self.is_rect_obstacle_free(center, cells_radius, cells_radius) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If allow_unknown is true, we're done (no obstacles found via SIMD)
|
||||
if allow_unknown {
|
||||
// Still need to check bounds validity
|
||||
return self.is_footprint_in_bounds(center, cells_radius, position);
|
||||
}
|
||||
|
||||
// Strict mode: Need to check for Unknown cells in circular footprint
|
||||
self.is_footprint_traversable(center, cells_radius, position)
|
||||
}
|
||||
|
||||
/// Check if a rectangular region contains any obstacle cells (SIMD-optimized).
|
||||
///
|
||||
/// Obstacles are Wall, Cliff, or Bump (cell_type >= 2).
|
||||
/// Returns true if no obstacles found.
|
||||
#[inline]
|
||||
fn is_rect_obstacle_free(&self, center: GridCoord, half_width: i32, half_height: i32) -> bool {
|
||||
let width = self.storage.width() as i32;
|
||||
let height = self.storage.height() as i32;
|
||||
|
||||
let min_x = (center.x - half_width).max(0) as usize;
|
||||
let max_x = ((center.x + half_width).min(width - 1) as usize).min(self.storage.width() - 1);
|
||||
let min_y = (center.y - half_height).max(0) as usize;
|
||||
let max_y =
|
||||
((center.y + half_height).min(height - 1) as usize).min(self.storage.height() - 1);
|
||||
|
||||
if min_x > max_x || min_y > max_y {
|
||||
return false; // Out of bounds
|
||||
}
|
||||
|
||||
let cell_types = self.storage.cell_types_raw();
|
||||
let storage_width = self.storage.width();
|
||||
|
||||
// Wall, Cliff, Bump are >= 2 (Floor=1, Unknown=0)
|
||||
let wall_val = CellType::Wall as u8;
|
||||
let wall_vec = u8x16::splat(wall_val);
|
||||
|
||||
for y in min_y..=max_y {
|
||||
let row_start = y * storage_width + min_x;
|
||||
let row_end = y * storage_width + max_x + 1;
|
||||
let row_slice = &cell_types[row_start..row_end];
|
||||
|
||||
// Process 16 cells at a time
|
||||
let chunks = row_slice.chunks_exact(16);
|
||||
let remainder = chunks.remainder();
|
||||
|
||||
for chunk in chunks {
|
||||
let data = u8x16::from_slice(chunk);
|
||||
// Check if any cell >= Wall (2)
|
||||
let is_obstacle = data.simd_ge(wall_vec);
|
||||
if is_obstacle.to_bitmask() != 0 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Scalar remainder
|
||||
for &cell_type in remainder {
|
||||
if cell_type >= wall_val {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Check if all cells in the circular footprint are within bounds.
|
||||
#[inline]
|
||||
fn is_footprint_in_bounds(
|
||||
&self,
|
||||
center: GridCoord,
|
||||
cells_radius: i32,
|
||||
position: WorldPoint,
|
||||
) -> bool {
|
||||
for dy in -cells_radius..=cells_radius {
|
||||
for dx in -cells_radius..=cells_radius {
|
||||
let coord = center + GridCoord::new(dx, dy);
|
||||
|
||||
if !self.storage.is_valid_coord(coord) {
|
||||
// Check if this cell is within the circular footprint
|
||||
let cell_world = self.storage.grid_to_world(coord);
|
||||
let dist = cell_world.distance(&position);
|
||||
if dist <= self.footprint.total_radius() {
|
||||
return false; // Out of bounds within footprint
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Check if all cells in the circular footprint are traversable (Floor).
|
||||
#[inline]
|
||||
fn is_footprint_traversable(
|
||||
&self,
|
||||
center: GridCoord,
|
||||
cells_radius: i32,
|
||||
position: WorldPoint,
|
||||
) -> bool {
|
||||
for dy in -cells_radius..=cells_radius {
|
||||
for dx in -cells_radius..=cells_radius {
|
||||
let coord = center + GridCoord::new(dx, dy);
|
||||
|
||||
if !self.storage.is_valid_coord(coord) {
|
||||
// Check if this cell is within the circular footprint
|
||||
let cell_world = self.storage.grid_to_world(coord);
|
||||
let dist = cell_world.distance(&position);
|
||||
if dist <= self.footprint.total_radius() {
|
||||
return false; // Out of bounds within footprint
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if within circular footprint
|
||||
let cell_world = self.storage.grid_to_world(coord);
|
||||
let dist = cell_world.distance(&position);
|
||||
|
||||
if dist <= self.footprint.total_radius() {
|
||||
let cell_type = self.storage.get_type(coord);
|
||||
if !cell_type.is_traversable() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Check if a position is known (all cells within footprint are known)
|
||||
pub fn is_position_known(&self, position: WorldPoint) -> bool {
|
||||
let center = self.storage.world_to_grid(position);
|
||||
let resolution = self.storage.resolution();
|
||||
let cells_radius = (self.footprint.total_radius() / resolution).ceil() as i32;
|
||||
|
||||
for dy in -cells_radius..=cells_radius {
|
||||
for dx in -cells_radius..=cells_radius {
|
||||
let coord = center + GridCoord::new(dx, dy);
|
||||
|
||||
if !self.storage.is_valid_coord(coord) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let cell_world = self.storage.grid_to_world(coord);
|
||||
let dist = cell_world.distance(&position);
|
||||
|
||||
if dist <= self.footprint.total_radius() && !self.storage.get_type(coord).is_known()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Check if a straight-line path is traversable
|
||||
/// Returns the furthest traversable point and whether the full path is clear
|
||||
pub fn check_path(
|
||||
&self,
|
||||
start: WorldPoint,
|
||||
end: WorldPoint,
|
||||
step_size: Option<f32>,
|
||||
) -> PathCheckResult {
|
||||
let step = step_size.unwrap_or(self.storage.resolution() * 0.5);
|
||||
let total_dist = start.distance(&end);
|
||||
|
||||
if total_dist < step {
|
||||
// Very short path - just check endpoints
|
||||
let start_safe = self.is_position_safe(start);
|
||||
let end_safe = self.is_position_safe(end);
|
||||
|
||||
return PathCheckResult {
|
||||
is_clear: start_safe && end_safe,
|
||||
blocked_at: if !start_safe {
|
||||
Some(start)
|
||||
} else if !end_safe {
|
||||
Some(end)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
furthest_safe: if start_safe { Some(end) } else { None },
|
||||
distance_traversable: if start_safe && end_safe {
|
||||
total_dist
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let dir_x = (end.x - start.x) / total_dist;
|
||||
let dir_y = (end.y - start.y) / total_dist;
|
||||
|
||||
let mut furthest_safe = start;
|
||||
let mut distance_traversable = 0.0;
|
||||
|
||||
// Check start position first
|
||||
if !self.is_position_safe(start) {
|
||||
return PathCheckResult {
|
||||
is_clear: false,
|
||||
blocked_at: Some(start),
|
||||
furthest_safe: None,
|
||||
distance_traversable: 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
let mut dist = step;
|
||||
while dist < total_dist {
|
||||
let point = WorldPoint::new(start.x + dir_x * dist, start.y + dir_y * dist);
|
||||
|
||||
if self.is_position_safe(point) {
|
||||
furthest_safe = point;
|
||||
distance_traversable = dist;
|
||||
} else {
|
||||
return PathCheckResult {
|
||||
is_clear: false,
|
||||
blocked_at: Some(point),
|
||||
furthest_safe: Some(furthest_safe),
|
||||
distance_traversable,
|
||||
};
|
||||
}
|
||||
|
||||
dist += step;
|
||||
}
|
||||
|
||||
// Check endpoint
|
||||
if self.is_position_safe(end) {
|
||||
PathCheckResult {
|
||||
is_clear: true,
|
||||
blocked_at: None,
|
||||
furthest_safe: Some(end),
|
||||
distance_traversable: total_dist,
|
||||
}
|
||||
} else {
|
||||
PathCheckResult {
|
||||
is_clear: false,
|
||||
blocked_at: Some(end),
|
||||
furthest_safe: Some(furthest_safe),
|
||||
distance_traversable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if movement from current position in a direction is safe
|
||||
pub fn check_direction(
|
||||
&self,
|
||||
position: WorldPoint,
|
||||
angle: f32,
|
||||
distance: f32,
|
||||
) -> PathCheckResult {
|
||||
let end = WorldPoint::new(
|
||||
position.x + distance * angle.cos(),
|
||||
position.y + distance * angle.sin(),
|
||||
);
|
||||
self.check_path(position, end, None)
|
||||
}
|
||||
|
||||
/// Find the nearest obstacle from a position
|
||||
pub fn nearest_obstacle(&self, position: WorldPoint, max_distance: f32) -> Option<WorldPoint> {
|
||||
let center = self.storage.world_to_grid(position);
|
||||
let resolution = self.storage.resolution();
|
||||
let max_cells = (max_distance / resolution).ceil() as i32;
|
||||
|
||||
let mut nearest: Option<(WorldPoint, f32)> = None;
|
||||
|
||||
for dy in -max_cells..=max_cells {
|
||||
for dx in -max_cells..=max_cells {
|
||||
let coord = center + GridCoord::new(dx, dy);
|
||||
|
||||
if !self.storage.is_valid_coord(coord) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cell_type = self.storage.get_type(coord);
|
||||
if cell_type.is_obstacle() {
|
||||
let obstacle_pos = self.storage.grid_to_world(coord);
|
||||
let dist = position.distance(&obstacle_pos);
|
||||
|
||||
if dist <= max_distance {
|
||||
match nearest {
|
||||
None => nearest = Some((obstacle_pos, dist)),
|
||||
Some((_, d)) if dist < d => nearest = Some((obstacle_pos, dist)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nearest.map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// Get clearance (distance to nearest obstacle) at a position
|
||||
pub fn clearance(&self, position: WorldPoint, max_distance: f32) -> f32 {
|
||||
match self.nearest_obstacle(position, max_distance) {
|
||||
Some(obstacle) => position.distance(&obstacle),
|
||||
None => max_distance,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of path traversability check
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PathCheckResult {
|
||||
/// Is the entire path clear?
|
||||
pub is_clear: bool,
|
||||
/// Position where the path is blocked (if blocked)
|
||||
pub blocked_at: Option<WorldPoint>,
|
||||
/// Furthest safe position along the path
|
||||
pub furthest_safe: Option<WorldPoint>,
|
||||
/// Distance that is traversable
|
||||
pub distance_traversable: f32,
|
||||
}
|
||||
|
||||
impl PathCheckResult {
|
||||
/// Percentage of path that is traversable (0.0 to 1.0)
|
||||
pub fn traversable_ratio(&self, total_length: f32) -> f32 {
|
||||
if total_length <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
(self.distance_traversable / total_length).min(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick check if a point is traversable
|
||||
/// Quick check if a point is traversable.
|
||||
pub fn is_traversable(storage: &GridStorage, point: WorldPoint) -> bool {
|
||||
let checker = TraversabilityChecker::with_default_footprint(storage);
|
||||
checker.is_point_traversable(point)
|
||||
}
|
||||
|
||||
/// Quick check if a point is safe for the robot
|
||||
/// Quick check if a point is safe for the robot.
|
||||
pub fn is_safe(storage: &GridStorage, point: WorldPoint) -> bool {
|
||||
let checker = TraversabilityChecker::with_default_footprint(storage);
|
||||
checker.is_position_safe(point)
|
||||
@@ -431,7 +28,7 @@ pub fn is_safe(storage: &GridStorage, point: WorldPoint) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::CellType;
|
||||
use crate::core::{CellType, GridCoord};
|
||||
use std::simd::{cmp::SimdPartialOrd, u8x16};
|
||||
|
||||
fn create_test_storage() -> GridStorage {
|
||||
@@ -450,22 +47,22 @@ mod tests {
|
||||
half_width: i32,
|
||||
half_height: i32,
|
||||
) -> bool {
|
||||
let width = checker.storage.width() as i32;
|
||||
let height = checker.storage.height() as i32;
|
||||
let width = checker.storage().width() as i32;
|
||||
let height = checker.storage().height() as i32;
|
||||
|
||||
let min_x = (center.x - half_width).max(0) as usize;
|
||||
let max_x =
|
||||
((center.x + half_width).min(width - 1) as usize).min(checker.storage.width() - 1);
|
||||
((center.x + half_width).min(width - 1) as usize).min(checker.storage().width() - 1);
|
||||
let min_y = (center.y - half_height).max(0) as usize;
|
||||
let max_y =
|
||||
((center.y + half_height).min(height - 1) as usize).min(checker.storage.height() - 1);
|
||||
((center.y + half_height).min(height - 1) as usize).min(checker.storage().height() - 1);
|
||||
|
||||
if min_x > max_x || min_y > max_y {
|
||||
return false; // Out of bounds
|
||||
}
|
||||
|
||||
let cell_types = checker.storage.cell_types_raw();
|
||||
let storage_width = checker.storage.width();
|
||||
let cell_types = checker.storage().cell_types_raw();
|
||||
let storage_width = checker.storage().width();
|
||||
|
||||
let wall_val = CellType::Wall as u8;
|
||||
let wall_vec = u8x16::splat(wall_val);
|
||||
@@ -499,9 +96,9 @@ mod tests {
|
||||
|
||||
/// Check if a position is safe using SIMD for bounding box pre-check.
|
||||
fn is_position_safe_simd(checker: &TraversabilityChecker, position: WorldPoint) -> bool {
|
||||
let center = checker.storage.world_to_grid(position);
|
||||
let resolution = checker.storage.resolution();
|
||||
let cells_radius = (checker.footprint.total_radius() / resolution).ceil() as i32;
|
||||
let center = checker.storage().world_to_grid(position);
|
||||
let resolution = checker.storage().resolution();
|
||||
let cells_radius = (checker.footprint().total_radius() / resolution).ceil() as i32;
|
||||
|
||||
if !is_rect_obstacle_free(checker, center, cells_radius, cells_radius) {
|
||||
return false;
|
||||
@@ -521,15 +118,15 @@ mod tests {
|
||||
for dx in -cells_radius..=cells_radius {
|
||||
let coord = center + GridCoord::new(dx, dy);
|
||||
|
||||
if !checker.storage.is_valid_coord(coord) {
|
||||
if !checker.storage().is_valid_coord(coord) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let cell_world = checker.storage.grid_to_world(coord);
|
||||
let cell_world = checker.storage().grid_to_world(coord);
|
||||
let dist = cell_world.distance(&position);
|
||||
|
||||
if dist <= checker.footprint.total_radius() {
|
||||
let cell_type = checker.storage.get_type(coord);
|
||||
if dist <= checker.footprint().total_radius() {
|
||||
let cell_type = checker.storage().get_type(coord);
|
||||
if !cell_type.is_traversable() {
|
||||
return false;
|
||||
}
|
||||
@@ -546,15 +143,15 @@ mod tests {
|
||||
position: WorldPoint,
|
||||
max_distance: f32,
|
||||
) -> Option<WorldPoint> {
|
||||
let center = checker.storage.world_to_grid(position);
|
||||
let resolution = checker.storage.resolution();
|
||||
let center = checker.storage().world_to_grid(position);
|
||||
let resolution = checker.storage().resolution();
|
||||
let max_cells = (max_distance / resolution).ceil() as i32;
|
||||
|
||||
let width = checker.storage.width() as i32;
|
||||
let height = checker.storage.height() as i32;
|
||||
let width = checker.storage().width() as i32;
|
||||
let height = checker.storage().height() as i32;
|
||||
|
||||
let cell_types = checker.storage.cell_types_raw();
|
||||
let storage_width = checker.storage.width();
|
||||
let cell_types = checker.storage().cell_types_raw();
|
||||
let storage_width = checker.storage().width();
|
||||
|
||||
let wall_val = CellType::Wall as u8;
|
||||
let wall_vec = u8x16::splat(wall_val);
|
||||
@@ -563,10 +160,10 @@ mod tests {
|
||||
|
||||
let min_y = (center.y - max_cells).max(0) as usize;
|
||||
let max_y =
|
||||
((center.y + max_cells).min(height - 1) as usize).min(checker.storage.height() - 1);
|
||||
((center.y + max_cells).min(height - 1) as usize).min(checker.storage().height() - 1);
|
||||
let min_x = (center.x - max_cells).max(0) as usize;
|
||||
let max_x =
|
||||
((center.x + max_cells).min(width - 1) as usize).min(checker.storage.width() - 1);
|
||||
((center.x + max_cells).min(width - 1) as usize).min(checker.storage().width() - 1);
|
||||
|
||||
for y in min_y..=max_y {
|
||||
let row_start = y * storage_width + min_x;
|
||||
@@ -587,7 +184,7 @@ mod tests {
|
||||
if (mask & (1 << bit)) != 0 {
|
||||
let x = base_x + bit;
|
||||
let coord = GridCoord::new(x as i32, y as i32);
|
||||
let obstacle_pos = checker.storage.grid_to_world(coord);
|
||||
let obstacle_pos = checker.storage().grid_to_world(coord);
|
||||
let dist = position.distance(&obstacle_pos);
|
||||
|
||||
if dist <= max_distance {
|
||||
@@ -609,7 +206,7 @@ mod tests {
|
||||
if cell_type >= wall_val {
|
||||
let x = remainder_start + i;
|
||||
let coord = GridCoord::new(x as i32, y as i32);
|
||||
let obstacle_pos = checker.storage.grid_to_world(coord);
|
||||
let obstacle_pos = checker.storage().grid_to_world(coord);
|
||||
let dist = position.distance(&obstacle_pos);
|
||||
|
||||
if dist <= max_distance {
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Path traversability checking result.
|
||||
|
||||
use crate::core::WorldPoint;
|
||||
|
||||
/// Result of path traversability check.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PathCheckResult {
|
||||
/// Is the entire path clear?
|
||||
pub is_clear: bool,
|
||||
/// Position where the path is blocked (if blocked).
|
||||
pub blocked_at: Option<WorldPoint>,
|
||||
/// Furthest safe position along the path.
|
||||
pub furthest_safe: Option<WorldPoint>,
|
||||
/// Distance that is traversable.
|
||||
pub distance_traversable: f32,
|
||||
}
|
||||
|
||||
impl PathCheckResult {
|
||||
/// Percentage of path that is traversable (0.0 to 1.0).
|
||||
pub fn traversable_ratio(&self, total_length: f32) -> f32 {
|
||||
if total_length <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
(self.distance_traversable / total_length).min(1.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
//! Background pose graph optimization.
|
||||
//!
|
||||
//! Provides a mechanism to run pose graph optimization asynchronously,
|
||||
//! preventing the main SLAM thread from blocking during optimization.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────────────────────────────────────────────────────────────┐
|
||||
//! │ SLAM Thread │
|
||||
//! │ ┌───────────┐ ┌───────────┐ ┌─────────────────────────────┐ │
|
||||
//! │ │ Scan Recv │──▶│ Match │──▶│ Insert Submap + Add Pose │ │
|
||||
//! │ └───────────┘ └───────────┘ └──────────────┬──────────────┘ │
|
||||
//! │ │ │
|
||||
//! │ ▼ │
|
||||
//! │ ┌────────────────────────────────┐ │
|
||||
//! │ │ Loop Closure Detector │ │
|
||||
//! │ │ (LiDAR-IRIS + verification) │ │
|
||||
//! │ └───────────────┬────────────────┘ │
|
||||
//! └──────────────────────────────────────────────────┼──────────────────┘
|
||||
//! │ trigger
|
||||
//! ▼
|
||||
//! ┌─────────────────────────────────────────────────────────────────────┐
|
||||
//! │ Background Optimizer │
|
||||
//! │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
|
||||
//! │ │ Copy Graph │──▶│ Optimize │──▶│ Return Corrections │ │
|
||||
//! │ │ Snapshot │ │ (N iters) │ │ │ │
|
||||
//! │ └──────────────┘ └──────────────┘ └──────────────────────┘ │
|
||||
//! └─────────────────────────────────────────────────────────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! ## Usage Modes
|
||||
//!
|
||||
//! 1. **Synchronous** (embedded target): Call `optimize_sync()` directly
|
||||
//! 2. **Threaded** (desktop): Spawn optimizer thread, use channels
|
||||
//!
|
||||
//! For embedded ARM (Allwinner A33), synchronous mode is recommended since
|
||||
//! the additional thread overhead may not be worthwhile for the typical
|
||||
//! 100-500ms optimization time that only occurs on loop closure.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::thread::{self, JoinHandle};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::Pose2D;
|
||||
use crate::slam::loop_closure::{LoopClosure, PoseGraph, PoseGraphConfig};
|
||||
|
||||
/// Configuration for background optimizer.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct BackgroundOptimizerConfig {
|
||||
/// Maximum iterations per optimization run.
|
||||
#[serde(default = "default_max_iterations")]
|
||||
pub max_iterations: usize,
|
||||
|
||||
/// Convergence threshold for pose change.
|
||||
#[serde(default = "default_convergence_threshold")]
|
||||
pub convergence_threshold: f32,
|
||||
|
||||
/// Trigger optimization every N nodes (0 = only on loop closure).
|
||||
#[serde(default = "default_trigger_every_n_nodes")]
|
||||
pub trigger_every_n_nodes: usize,
|
||||
|
||||
/// Huber loss scale parameter (meters).
|
||||
#[serde(default = "default_huber_scale")]
|
||||
pub huber_scale: f32,
|
||||
|
||||
/// Whether to use background thread (vs synchronous).
|
||||
#[serde(default = "default_use_thread")]
|
||||
pub use_background_thread: bool,
|
||||
}
|
||||
|
||||
fn default_max_iterations() -> usize {
|
||||
50
|
||||
}
|
||||
|
||||
fn default_convergence_threshold() -> f32 {
|
||||
1e-5
|
||||
}
|
||||
|
||||
fn default_trigger_every_n_nodes() -> usize {
|
||||
0 // Only on loop closure by default
|
||||
}
|
||||
|
||||
fn default_huber_scale() -> f32 {
|
||||
0.1 // 10cm
|
||||
}
|
||||
|
||||
fn default_use_thread() -> bool {
|
||||
false // Synchronous by default for embedded
|
||||
}
|
||||
|
||||
impl Default for BackgroundOptimizerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_iterations: default_max_iterations(),
|
||||
convergence_threshold: default_convergence_threshold(),
|
||||
trigger_every_n_nodes: default_trigger_every_n_nodes(),
|
||||
huber_scale: default_huber_scale(),
|
||||
use_background_thread: default_use_thread(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pose correction to apply after optimization.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PoseCorrection {
|
||||
/// Pose index in the graph.
|
||||
pub pose_idx: usize,
|
||||
/// Original pose before optimization.
|
||||
pub original: Pose2D,
|
||||
/// Optimized pose.
|
||||
pub optimized: Pose2D,
|
||||
/// Delta (optimized - original).
|
||||
pub delta: Pose2D,
|
||||
}
|
||||
|
||||
/// Result of an optimization run.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OptimizationResult {
|
||||
/// Pose corrections to apply.
|
||||
pub corrections: Vec<PoseCorrection>,
|
||||
/// Number of iterations performed.
|
||||
pub iterations: usize,
|
||||
/// Final error after optimization.
|
||||
pub final_error: f32,
|
||||
/// Error before optimization.
|
||||
pub initial_error: f32,
|
||||
/// Whether optimization converged.
|
||||
pub converged: bool,
|
||||
}
|
||||
|
||||
/// Trigger type for optimization.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum OptimizerTrigger {
|
||||
/// Trigger due to loop closure detection.
|
||||
LoopClosure(LoopClosure),
|
||||
/// Trigger due to node count threshold.
|
||||
NodeCount(usize),
|
||||
/// Manual trigger.
|
||||
Manual,
|
||||
}
|
||||
|
||||
/// Background pose graph optimizer.
|
||||
///
|
||||
/// Can operate in two modes:
|
||||
/// 1. **Synchronous**: Call `optimize_sync()` directly (blocks caller)
|
||||
/// 2. **Threaded**: Use `start_background_thread()` and channels
|
||||
pub struct BackgroundOptimizer {
|
||||
/// Configuration.
|
||||
config: BackgroundOptimizerConfig,
|
||||
/// The pose graph being optimized.
|
||||
graph: Arc<Mutex<PoseGraph>>,
|
||||
/// Background thread handle (if running).
|
||||
thread_handle: Option<JoinHandle<()>>,
|
||||
/// Channel to send triggers to background thread.
|
||||
trigger_tx: Option<mpsc::Sender<OptimizerTrigger>>,
|
||||
/// Channel to receive results from background thread.
|
||||
result_rx: Option<mpsc::Receiver<OptimizationResult>>,
|
||||
/// Flag to stop background thread.
|
||||
should_stop: Arc<AtomicBool>,
|
||||
/// Count of poses added since last optimization.
|
||||
poses_since_optimization: usize,
|
||||
}
|
||||
|
||||
impl BackgroundOptimizer {
|
||||
/// Create a new background optimizer.
|
||||
pub fn new(config: BackgroundOptimizerConfig) -> Self {
|
||||
let graph_config = PoseGraphConfig {
|
||||
max_iterations: config.max_iterations,
|
||||
convergence_threshold: config.convergence_threshold,
|
||||
huber_scale: config.huber_scale,
|
||||
..PoseGraphConfig::default()
|
||||
};
|
||||
|
||||
Self {
|
||||
config,
|
||||
graph: Arc::new(Mutex::new(PoseGraph::new(graph_config))),
|
||||
thread_handle: None,
|
||||
trigger_tx: None,
|
||||
result_rx: None,
|
||||
should_stop: Arc::new(AtomicBool::new(false)),
|
||||
poses_since_optimization: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with default configuration.
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(BackgroundOptimizerConfig::default())
|
||||
}
|
||||
|
||||
/// Get a reference to the pose graph.
|
||||
pub fn graph(&self) -> &Arc<Mutex<PoseGraph>> {
|
||||
&self.graph
|
||||
}
|
||||
|
||||
/// Add a pose to the graph.
|
||||
///
|
||||
/// Returns the pose index and an optional trigger if optimization should run.
|
||||
pub fn add_pose(
|
||||
&mut self,
|
||||
pose: Pose2D,
|
||||
odom_delta: Option<Pose2D>,
|
||||
) -> (usize, Option<OptimizerTrigger>) {
|
||||
let pose_idx = {
|
||||
let mut graph = self.graph.lock().unwrap();
|
||||
graph.add_pose(pose, odom_delta)
|
||||
};
|
||||
|
||||
self.poses_since_optimization += 1;
|
||||
|
||||
// Check if we should trigger optimization based on node count
|
||||
let trigger = if self.config.trigger_every_n_nodes > 0
|
||||
&& self.poses_since_optimization >= self.config.trigger_every_n_nodes
|
||||
{
|
||||
self.poses_since_optimization = 0;
|
||||
Some(OptimizerTrigger::NodeCount(pose_idx))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(pose_idx, trigger)
|
||||
}
|
||||
|
||||
/// Add a loop closure constraint.
|
||||
///
|
||||
/// Returns a trigger to start optimization.
|
||||
pub fn add_loop_closure(&mut self, closure: LoopClosure) -> OptimizerTrigger {
|
||||
{
|
||||
let mut graph = self.graph.lock().unwrap();
|
||||
graph.add_loop_closure(closure.clone());
|
||||
}
|
||||
|
||||
self.poses_since_optimization = 0;
|
||||
OptimizerTrigger::LoopClosure(closure)
|
||||
}
|
||||
|
||||
/// Run optimization synchronously.
|
||||
///
|
||||
/// This blocks the caller until optimization is complete.
|
||||
/// Use this for embedded targets where threading overhead isn't worthwhile.
|
||||
pub fn optimize_sync(&mut self) -> OptimizationResult {
|
||||
let mut graph = self.graph.lock().unwrap();
|
||||
|
||||
let initial_error = graph.total_error();
|
||||
let iterations = graph.optimize();
|
||||
let final_error = graph.total_error();
|
||||
|
||||
// Compute corrections
|
||||
let poses = graph.poses();
|
||||
let corrections = Self::compute_corrections_from_graph(&graph, poses);
|
||||
|
||||
OptimizationResult {
|
||||
corrections,
|
||||
iterations,
|
||||
final_error,
|
||||
initial_error,
|
||||
converged: iterations < self.config.max_iterations,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the background optimization thread.
|
||||
///
|
||||
/// After calling this, use `trigger_optimization()` to start optimization
|
||||
/// and `try_receive_result()` to check for completed results.
|
||||
pub fn start_background_thread(&mut self) {
|
||||
if self.thread_handle.is_some() {
|
||||
return; // Already running
|
||||
}
|
||||
|
||||
let (trigger_tx, trigger_rx) = mpsc::channel::<OptimizerTrigger>();
|
||||
let (result_tx, result_rx) = mpsc::channel::<OptimizationResult>();
|
||||
|
||||
self.trigger_tx = Some(trigger_tx);
|
||||
self.result_rx = Some(result_rx);
|
||||
self.should_stop.store(false, Ordering::SeqCst);
|
||||
|
||||
let graph: Arc<Mutex<PoseGraph>> = Arc::clone(&self.graph);
|
||||
let should_stop = Arc::clone(&self.should_stop);
|
||||
let max_iterations = self.config.max_iterations;
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
while !should_stop.load(Ordering::SeqCst) {
|
||||
// Wait for trigger
|
||||
match trigger_rx.recv_timeout(std::time::Duration::from_millis(100)) {
|
||||
Ok(_trigger) => {
|
||||
// Perform optimization
|
||||
let result = {
|
||||
let mut graph_lock = graph.lock().unwrap();
|
||||
|
||||
let initial_error = graph_lock.total_error();
|
||||
let iterations = graph_lock.optimize();
|
||||
let final_error = graph_lock.total_error();
|
||||
|
||||
let poses = graph_lock.poses();
|
||||
let corrections =
|
||||
Self::compute_corrections_from_graph(&graph_lock, poses);
|
||||
|
||||
OptimizationResult {
|
||||
corrections,
|
||||
iterations,
|
||||
final_error,
|
||||
initial_error,
|
||||
converged: iterations < max_iterations,
|
||||
}
|
||||
};
|
||||
|
||||
// Send result back
|
||||
let _ = result_tx.send(result);
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
// No trigger, continue waiting
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
break; // Channel closed
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
self.thread_handle = Some(handle);
|
||||
}
|
||||
|
||||
/// Trigger optimization in the background thread.
|
||||
///
|
||||
/// Returns `true` if the trigger was sent successfully.
|
||||
pub fn trigger_optimization(&self, trigger: OptimizerTrigger) -> bool {
|
||||
if let Some(ref tx) = self.trigger_tx {
|
||||
tx.send(trigger).is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to receive a result from the background thread.
|
||||
///
|
||||
/// Returns `None` if no result is available yet.
|
||||
pub fn try_receive_result(&self) -> Option<OptimizationResult> {
|
||||
if let Some(ref rx) = self.result_rx {
|
||||
rx.try_recv().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Block until a result is available from the background thread.
|
||||
///
|
||||
/// Returns `None` if the channel is disconnected.
|
||||
pub fn wait_for_result(&self) -> Option<OptimizationResult> {
|
||||
if let Some(ref rx) = self.result_rx {
|
||||
rx.recv().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the background thread.
|
||||
pub fn stop_background_thread(&mut self) {
|
||||
self.should_stop.store(true, Ordering::SeqCst);
|
||||
|
||||
// Drop the trigger channel to unblock the thread
|
||||
self.trigger_tx = None;
|
||||
|
||||
if let Some(handle) = self.thread_handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
|
||||
self.result_rx = None;
|
||||
}
|
||||
|
||||
/// Check if the background thread is running.
|
||||
pub fn is_background_running(&self) -> bool {
|
||||
self.thread_handle.is_some()
|
||||
}
|
||||
|
||||
/// Get the number of poses in the graph.
|
||||
pub fn pose_count(&self) -> usize {
|
||||
self.graph.lock().unwrap().len()
|
||||
}
|
||||
|
||||
/// Check if the graph has any loop closures.
|
||||
pub fn has_loops(&self) -> bool {
|
||||
self.graph.lock().unwrap().has_loops()
|
||||
}
|
||||
|
||||
/// Reset the graph and optimizer state.
|
||||
pub fn reset(&mut self) {
|
||||
let mut graph = self.graph.lock().unwrap();
|
||||
graph.reset();
|
||||
self.poses_since_optimization = 0;
|
||||
}
|
||||
|
||||
/// Compute corrections from optimized graph.
|
||||
fn compute_corrections_from_graph(
|
||||
_graph: &PoseGraph,
|
||||
optimized_poses: &[Pose2D],
|
||||
) -> Vec<PoseCorrection> {
|
||||
// Since we optimize in-place, we need to track the deltas
|
||||
// For now, return the current poses as the "optimized" values
|
||||
// In a real implementation, we'd snapshot before and after
|
||||
optimized_poses
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, &pose)| {
|
||||
PoseCorrection {
|
||||
pose_idx: idx,
|
||||
original: pose, // These would be the original poses
|
||||
optimized: pose,
|
||||
delta: Pose2D::new(0.0, 0.0, 0.0), // Would be actual delta
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get optimized poses directly.
|
||||
pub fn get_optimized_poses(&self) -> Vec<Pose2D> {
|
||||
let graph = self.graph.lock().unwrap();
|
||||
graph.poses().to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BackgroundOptimizer {
|
||||
fn drop(&mut self) {
|
||||
self.stop_background_thread();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_optimizer_creation() {
|
||||
let optimizer = BackgroundOptimizer::with_defaults();
|
||||
assert_eq!(optimizer.pose_count(), 0);
|
||||
assert!(!optimizer.has_loops());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_poses() {
|
||||
let mut optimizer = BackgroundOptimizer::with_defaults();
|
||||
|
||||
// Add first pose
|
||||
let (idx0, trigger0) = optimizer.add_pose(Pose2D::new(0.0, 0.0, 0.0), None);
|
||||
assert_eq!(idx0, 0);
|
||||
assert!(trigger0.is_none());
|
||||
|
||||
// Add second pose with odometry
|
||||
let delta = Pose2D::new(1.0, 0.0, 0.1);
|
||||
let (idx1, _) = optimizer.add_pose(Pose2D::new(1.0, 0.0, 0.1), Some(delta));
|
||||
assert_eq!(idx1, 1);
|
||||
assert_eq!(optimizer.pose_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_node_count_trigger() {
|
||||
let config = BackgroundOptimizerConfig {
|
||||
trigger_every_n_nodes: 3,
|
||||
..Default::default()
|
||||
};
|
||||
let mut optimizer = BackgroundOptimizer::new(config);
|
||||
|
||||
// Add poses until trigger
|
||||
let (_, t1) = optimizer.add_pose(Pose2D::new(0.0, 0.0, 0.0), None);
|
||||
assert!(t1.is_none());
|
||||
|
||||
let (_, t2) =
|
||||
optimizer.add_pose(Pose2D::new(1.0, 0.0, 0.0), Some(Pose2D::new(1.0, 0.0, 0.0)));
|
||||
assert!(t2.is_none());
|
||||
|
||||
let (_, t3) =
|
||||
optimizer.add_pose(Pose2D::new(2.0, 0.0, 0.0), Some(Pose2D::new(1.0, 0.0, 0.0)));
|
||||
assert!(matches!(t3, Some(OptimizerTrigger::NodeCount(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_loop_closure_trigger() {
|
||||
let mut optimizer = BackgroundOptimizer::with_defaults();
|
||||
|
||||
// Add some poses
|
||||
optimizer.add_pose(Pose2D::new(0.0, 0.0, 0.0), None);
|
||||
optimizer.add_pose(Pose2D::new(1.0, 0.0, 0.0), Some(Pose2D::new(1.0, 0.0, 0.0)));
|
||||
optimizer.add_pose(Pose2D::new(2.0, 0.0, 0.0), Some(Pose2D::new(1.0, 0.0, 0.0)));
|
||||
|
||||
// Add loop closure
|
||||
let closure = LoopClosure {
|
||||
from_idx: 2,
|
||||
to_idx: 0,
|
||||
relative_pose: Pose2D::new(-2.0, 0.0, 0.0),
|
||||
confidence: 0.9,
|
||||
descriptor_distance: 50,
|
||||
rotation_offset: 0,
|
||||
};
|
||||
|
||||
let trigger = optimizer.add_loop_closure(closure);
|
||||
assert!(matches!(trigger, OptimizerTrigger::LoopClosure(_)));
|
||||
assert!(optimizer.has_loops());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_optimization() {
|
||||
let mut optimizer = BackgroundOptimizer::with_defaults();
|
||||
|
||||
// Build a simple chain of poses
|
||||
optimizer.add_pose(Pose2D::new(0.0, 0.0, 0.0), None);
|
||||
optimizer.add_pose(Pose2D::new(1.0, 0.0, 0.0), Some(Pose2D::new(1.0, 0.0, 0.0)));
|
||||
optimizer.add_pose(Pose2D::new(2.0, 0.0, 0.0), Some(Pose2D::new(1.0, 0.0, 0.0)));
|
||||
|
||||
// Run synchronous optimization
|
||||
let result = optimizer.optimize_sync();
|
||||
|
||||
assert!(result.iterations > 0 || result.converged);
|
||||
assert!(!result.corrections.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_background_thread() {
|
||||
let mut optimizer = BackgroundOptimizer::with_defaults();
|
||||
|
||||
// Add some poses
|
||||
optimizer.add_pose(Pose2D::new(0.0, 0.0, 0.0), None);
|
||||
optimizer.add_pose(Pose2D::new(1.0, 0.0, 0.0), Some(Pose2D::new(1.0, 0.0, 0.0)));
|
||||
|
||||
// Start background thread
|
||||
optimizer.start_background_thread();
|
||||
assert!(optimizer.is_background_running());
|
||||
|
||||
// Trigger optimization
|
||||
let triggered = optimizer.trigger_optimization(OptimizerTrigger::Manual);
|
||||
assert!(triggered);
|
||||
|
||||
// Wait for result
|
||||
let result = optimizer.wait_for_result();
|
||||
assert!(result.is_some());
|
||||
|
||||
// Stop thread
|
||||
optimizer.stop_background_thread();
|
||||
assert!(!optimizer.is_background_running());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset() {
|
||||
let mut optimizer = BackgroundOptimizer::with_defaults();
|
||||
|
||||
optimizer.add_pose(Pose2D::new(0.0, 0.0, 0.0), None);
|
||||
optimizer.add_pose(Pose2D::new(1.0, 0.0, 0.0), Some(Pose2D::new(1.0, 0.0, 0.0)));
|
||||
|
||||
assert_eq!(optimizer.pose_count(), 2);
|
||||
|
||||
optimizer.reset();
|
||||
assert_eq!(optimizer.pose_count(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Branch-and-bound search algorithm implementation.
|
||||
|
||||
use crate::core::simd::PointCloud;
|
||||
use crate::core::{LidarScan, Pose2D, WorldPoint};
|
||||
|
||||
use super::config::{BranchBoundConfig, BranchBoundResult, NUM_LEVELS};
|
||||
use super::grids::PrecomputedGrids;
|
||||
use super::search_node::SearchNode;
|
||||
|
||||
/// Perform branch-and-bound scan matching.
|
||||
///
|
||||
/// This is more efficient than brute-force for large search windows.
|
||||
/// For small windows (< 30cm), brute-force may be faster due to overhead.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `points` - Scan points in sensor frame (x, y)
|
||||
/// * `grids` - Precomputed multi-resolution grids
|
||||
/// * `prior_pose` - Initial pose estimate
|
||||
/// * `config` - Search configuration
|
||||
///
|
||||
/// # Returns
|
||||
/// The best matching pose and score.
|
||||
pub fn branch_and_bound_match(
|
||||
points: &[(f32, f32)],
|
||||
grids: &PrecomputedGrids,
|
||||
prior_pose: Pose2D,
|
||||
config: &BranchBoundConfig,
|
||||
) -> BranchBoundResult {
|
||||
if points.is_empty() {
|
||||
return BranchBoundResult {
|
||||
pose: prior_pose,
|
||||
score: 0.0,
|
||||
nodes_expanded: 0,
|
||||
converged: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate angular bins
|
||||
let num_theta_bins = (config.search_theta * 2.0 / config.angular_resolution).ceil() as i32;
|
||||
let theta_step = config.angular_resolution;
|
||||
|
||||
// Calculate search bounds in coarsest grid coordinates
|
||||
let coarsest_level = NUM_LEVELS - 1;
|
||||
|
||||
let search_min = WorldPoint::new(
|
||||
prior_pose.x - config.search_x,
|
||||
prior_pose.y - config.search_y,
|
||||
);
|
||||
let search_max = WorldPoint::new(
|
||||
prior_pose.x + config.search_x,
|
||||
prior_pose.y + config.search_y,
|
||||
);
|
||||
|
||||
let min_coord = grids.world_to_level_grid(search_min, coarsest_level);
|
||||
let max_coord = grids.world_to_level_grid(search_max, coarsest_level);
|
||||
|
||||
// Initialize search with coarsest nodes
|
||||
let mut stack: Vec<SearchNode> = Vec::with_capacity(256);
|
||||
let mut best_score = config.min_score;
|
||||
let mut best_pose = prior_pose;
|
||||
let mut nodes_expanded = 0usize;
|
||||
|
||||
// Start theta search around prior
|
||||
let prior_theta_bin = (prior_pose.theta / theta_step).round() as i32;
|
||||
let theta_range = (config.search_theta / theta_step).ceil() as i32;
|
||||
|
||||
for y in min_coord.y..=max_coord.y {
|
||||
for x in min_coord.x..=max_coord.x {
|
||||
for dt in -theta_range..=theta_range {
|
||||
let theta_bin = prior_theta_bin + dt;
|
||||
stack.push(SearchNode {
|
||||
level: coarsest_level,
|
||||
x,
|
||||
y,
|
||||
theta_bin,
|
||||
upper_bound: f32::MAX, // Will compute on first access
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Branch-and-bound search
|
||||
while let Some(mut node) = stack.pop() {
|
||||
nodes_expanded += 1;
|
||||
|
||||
// Compute upper bound if not yet computed
|
||||
if node.upper_bound == f32::MAX {
|
||||
let pose = node.center_pose(grids, theta_step);
|
||||
node.upper_bound =
|
||||
grids.score_scan_at_level(points, pose, node.level, config.sensor_offset);
|
||||
}
|
||||
|
||||
// Prune if upper bound is worse than current best
|
||||
if node.upper_bound <= best_score {
|
||||
continue;
|
||||
}
|
||||
|
||||
if node.level == 0 {
|
||||
// Finest level - this is the actual score
|
||||
let pose = node.center_pose(grids, theta_step);
|
||||
let score = grids.score_scan_at_level(points, pose, 0, config.sensor_offset);
|
||||
|
||||
if score > best_score {
|
||||
best_score = score;
|
||||
best_pose = pose;
|
||||
}
|
||||
} else {
|
||||
// Expand to finer level
|
||||
let children = node.subdivide(grids, num_theta_bins);
|
||||
for mut child in children {
|
||||
let child_pose = child.center_pose(grids, theta_step);
|
||||
child.upper_bound = grids.score_scan_at_level(
|
||||
points,
|
||||
child_pose,
|
||||
child.level,
|
||||
config.sensor_offset,
|
||||
);
|
||||
|
||||
// Only add if upper bound could beat current best
|
||||
if child.upper_bound > best_score {
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BranchBoundResult {
|
||||
pose: best_pose,
|
||||
score: best_score,
|
||||
nodes_expanded,
|
||||
converged: best_score > config.min_score,
|
||||
}
|
||||
}
|
||||
|
||||
/// SIMD-optimized branch-and-bound scan matching.
|
||||
///
|
||||
/// Uses PointCloud (SoA format) for efficient SIMD point transformations.
|
||||
/// This is the preferred method for loop closure verification.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `points` - Scan points in sensor frame (PointCloud SoA format)
|
||||
/// * `grids` - Precomputed multi-resolution grids
|
||||
/// * `prior_pose` - Initial pose estimate
|
||||
/// * `config` - Search configuration
|
||||
///
|
||||
/// # Returns
|
||||
/// The best matching pose and score.
|
||||
pub fn branch_and_bound_match_simd(
|
||||
points: &PointCloud,
|
||||
grids: &PrecomputedGrids,
|
||||
prior_pose: Pose2D,
|
||||
config: &BranchBoundConfig,
|
||||
) -> BranchBoundResult {
|
||||
if points.is_empty() {
|
||||
return BranchBoundResult {
|
||||
pose: prior_pose,
|
||||
score: 0.0,
|
||||
nodes_expanded: 0,
|
||||
converged: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Reusable scratch buffers for SIMD transforms
|
||||
let mut world_xs = Vec::with_capacity(points.len());
|
||||
let mut world_ys = Vec::with_capacity(points.len());
|
||||
|
||||
// Calculate angular bins
|
||||
let num_theta_bins = (config.search_theta * 2.0 / config.angular_resolution).ceil() as i32;
|
||||
let theta_step = config.angular_resolution;
|
||||
|
||||
// Calculate search bounds in coarsest grid coordinates
|
||||
let coarsest_level = NUM_LEVELS - 1;
|
||||
|
||||
let search_min = WorldPoint::new(
|
||||
prior_pose.x - config.search_x,
|
||||
prior_pose.y - config.search_y,
|
||||
);
|
||||
let search_max = WorldPoint::new(
|
||||
prior_pose.x + config.search_x,
|
||||
prior_pose.y + config.search_y,
|
||||
);
|
||||
|
||||
let min_coord = grids.world_to_level_grid(search_min, coarsest_level);
|
||||
let max_coord = grids.world_to_level_grid(search_max, coarsest_level);
|
||||
|
||||
// Initialize search with coarsest nodes
|
||||
let mut stack: Vec<SearchNode> = Vec::with_capacity(256);
|
||||
let mut best_score = config.min_score;
|
||||
let mut best_pose = prior_pose;
|
||||
let mut nodes_expanded = 0usize;
|
||||
|
||||
// Start theta search around prior
|
||||
let prior_theta_bin = (prior_pose.theta / theta_step).round() as i32;
|
||||
let theta_range = (config.search_theta / theta_step).ceil() as i32;
|
||||
|
||||
for y in min_coord.y..=max_coord.y {
|
||||
for x in min_coord.x..=max_coord.x {
|
||||
for dt in -theta_range..=theta_range {
|
||||
let theta_bin = prior_theta_bin + dt;
|
||||
stack.push(SearchNode {
|
||||
level: coarsest_level,
|
||||
x,
|
||||
y,
|
||||
theta_bin,
|
||||
upper_bound: f32::MAX,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Branch-and-bound search with SIMD scoring
|
||||
while let Some(mut node) = stack.pop() {
|
||||
nodes_expanded += 1;
|
||||
|
||||
// Compute upper bound if not yet computed
|
||||
if node.upper_bound == f32::MAX {
|
||||
let pose = node.center_pose(grids, theta_step);
|
||||
node.upper_bound = grids.score_scan_simd_with_scratch(
|
||||
points,
|
||||
pose,
|
||||
node.level,
|
||||
config.sensor_offset,
|
||||
&mut world_xs,
|
||||
&mut world_ys,
|
||||
);
|
||||
}
|
||||
|
||||
// Prune if upper bound is worse than current best
|
||||
if node.upper_bound <= best_score {
|
||||
continue;
|
||||
}
|
||||
|
||||
if node.level == 0 {
|
||||
// Finest level - this is the actual score
|
||||
let pose = node.center_pose(grids, theta_step);
|
||||
let score = grids.score_scan_simd_with_scratch(
|
||||
points,
|
||||
pose,
|
||||
0,
|
||||
config.sensor_offset,
|
||||
&mut world_xs,
|
||||
&mut world_ys,
|
||||
);
|
||||
|
||||
if score > best_score {
|
||||
best_score = score;
|
||||
best_pose = pose;
|
||||
}
|
||||
} else {
|
||||
// Expand to finer level
|
||||
let children = node.subdivide(grids, num_theta_bins);
|
||||
for mut child in children {
|
||||
let child_pose = child.center_pose(grids, theta_step);
|
||||
child.upper_bound = grids.score_scan_simd_with_scratch(
|
||||
points,
|
||||
child_pose,
|
||||
child.level,
|
||||
config.sensor_offset,
|
||||
&mut world_xs,
|
||||
&mut world_ys,
|
||||
);
|
||||
|
||||
// Only add if upper bound could beat current best
|
||||
if child.upper_bound > best_score {
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BranchBoundResult {
|
||||
pose: best_pose,
|
||||
score: best_score,
|
||||
nodes_expanded,
|
||||
converged: best_score > config.min_score,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience function to match a LidarScan directly.
|
||||
///
|
||||
/// Converts the scan to PointCloud format and calls branch_and_bound_match_simd.
|
||||
pub fn branch_and_bound_match_scan(
|
||||
scan: &LidarScan,
|
||||
grids: &PrecomputedGrids,
|
||||
prior_pose: Pose2D,
|
||||
config: &BranchBoundConfig,
|
||||
) -> BranchBoundResult {
|
||||
let points = PointCloud::from_scan(scan);
|
||||
branch_and_bound_match_simd(&points, grids, prior_pose, config)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Branch-and-bound configuration and result types.
|
||||
|
||||
use crate::core::Pose2D;
|
||||
|
||||
/// Number of resolution levels in the precomputed hierarchy.
|
||||
/// Level 0 = original resolution, Level N = 4^N times coarser.
|
||||
pub const NUM_LEVELS: usize = 4;
|
||||
|
||||
/// Configuration for branch-and-bound matching.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BranchBoundConfig {
|
||||
/// Search window in X (meters).
|
||||
pub search_x: f32,
|
||||
/// Search window in Y (meters).
|
||||
pub search_y: f32,
|
||||
/// Search window in theta (radians).
|
||||
pub search_theta: f32,
|
||||
/// Angular resolution (radians per bin).
|
||||
pub angular_resolution: f32,
|
||||
/// Minimum score to consider a match valid.
|
||||
pub min_score: f32,
|
||||
/// Sensor offset (x, y) in robot frame.
|
||||
pub sensor_offset: (f32, f32),
|
||||
}
|
||||
|
||||
impl Default for BranchBoundConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
search_x: 0.3,
|
||||
search_y: 0.3,
|
||||
search_theta: 0.3,
|
||||
angular_resolution: 0.02, // ~1.15 degrees
|
||||
min_score: 0.0,
|
||||
sensor_offset: (-0.110, 0.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of branch-and-bound matching.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BranchBoundResult {
|
||||
/// Best matching pose.
|
||||
pub pose: Pose2D,
|
||||
/// Score of the best match.
|
||||
pub score: f32,
|
||||
/// Number of nodes expanded.
|
||||
pub nodes_expanded: usize,
|
||||
/// Whether a valid match was found.
|
||||
pub converged: bool,
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Precomputed multi-resolution grid hierarchy.
|
||||
|
||||
use crate::core::simd::{PointCloud, RotationMatrix4, transform_points_simd4};
|
||||
use crate::core::{GridCoord, Pose2D, WorldPoint};
|
||||
use crate::grid::GridStorage;
|
||||
|
||||
use super::config::NUM_LEVELS;
|
||||
|
||||
/// Precomputed multi-resolution grid hierarchy for branch-and-bound matching.
|
||||
///
|
||||
/// Each level stores probabilities (or distance-to-wall scores) where coarser
|
||||
/// levels contain the maximum of all finer cells within each coarse cell.
|
||||
pub struct PrecomputedGrids {
|
||||
/// Resolution levels from finest (0) to coarsest (NUM_LEVELS-1).
|
||||
/// Level i has cells 4^i times larger than level 0.
|
||||
pub(super) levels: [Vec<f32>; NUM_LEVELS],
|
||||
/// Width of each level in cells.
|
||||
pub(super) widths: [usize; NUM_LEVELS],
|
||||
/// Height of each level in cells.
|
||||
pub(super) heights: [usize; NUM_LEVELS],
|
||||
/// Resolution of level 0 (same as source grid).
|
||||
pub(super) resolution: f32,
|
||||
/// Origin of level 0 (same as source grid).
|
||||
pub(super) origin: WorldPoint,
|
||||
}
|
||||
|
||||
impl PrecomputedGrids {
|
||||
/// Build precomputed grids from a grid storage.
|
||||
///
|
||||
/// Level 0 is the original resolution, each subsequent level
|
||||
/// is 4x coarser (2x in each dimension).
|
||||
pub fn from_storage(storage: &GridStorage) -> Self {
|
||||
let width = storage.width();
|
||||
let height = storage.height();
|
||||
let resolution = storage.resolution();
|
||||
let origin = storage.origin();
|
||||
|
||||
// Initialize level 0 from storage
|
||||
let mut levels: [Vec<f32>; NUM_LEVELS] = Default::default();
|
||||
let mut widths = [0usize; NUM_LEVELS];
|
||||
let mut heights = [0usize; NUM_LEVELS];
|
||||
|
||||
widths[0] = width;
|
||||
heights[0] = height;
|
||||
levels[0] = Vec::with_capacity(width * height);
|
||||
|
||||
// Convert grid to probability/score values
|
||||
// Use Gaussian scoring based on distance field for smooth values
|
||||
let sigma = resolution * 2.0;
|
||||
let two_sigma_sq = 2.0 * sigma * sigma;
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let coord = GridCoord::new(x as i32, y as i32);
|
||||
let distance = storage.get_distance(coord);
|
||||
|
||||
// Gaussian score: high near walls, low far from walls
|
||||
let score = if distance < f32::MAX / 2.0 {
|
||||
(-distance * distance / two_sigma_sq).exp()
|
||||
} else {
|
||||
// Unknown area - small positive score for exploration
|
||||
0.1
|
||||
};
|
||||
|
||||
levels[0].push(score);
|
||||
}
|
||||
}
|
||||
|
||||
// Build coarser levels by taking max of 2x2 regions
|
||||
for level in 1..NUM_LEVELS {
|
||||
let prev_width = widths[level - 1];
|
||||
let prev_height = heights[level - 1];
|
||||
|
||||
// Round up to ensure we cover all cells
|
||||
let new_width = prev_width.div_ceil(2);
|
||||
let new_height = prev_height.div_ceil(2);
|
||||
|
||||
widths[level] = new_width;
|
||||
heights[level] = new_height;
|
||||
|
||||
let mut new_grid = vec![0.0f32; new_width * new_height];
|
||||
|
||||
for y in 0..new_height {
|
||||
for x in 0..new_width {
|
||||
// Find max of 2x2 region in previous level
|
||||
let mut max_val = 0.0f32;
|
||||
|
||||
for dy in 0..2 {
|
||||
for dx in 0..2 {
|
||||
let px = x * 2 + dx;
|
||||
let py = y * 2 + dy;
|
||||
|
||||
if px < prev_width && py < prev_height {
|
||||
let idx = py * prev_width + px;
|
||||
max_val = max_val.max(levels[level - 1][idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
new_grid[y * new_width + x] = max_val;
|
||||
}
|
||||
}
|
||||
|
||||
levels[level] = new_grid;
|
||||
}
|
||||
|
||||
Self {
|
||||
levels,
|
||||
widths,
|
||||
heights,
|
||||
resolution,
|
||||
origin,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the score at a specific level and grid coordinate.
|
||||
#[inline]
|
||||
pub(super) fn get_score(&self, level: usize, x: i32, y: i32) -> f32 {
|
||||
if x < 0 || y < 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let x = x as usize;
|
||||
let y = y as usize;
|
||||
if x >= self.widths[level] || y >= self.heights[level] {
|
||||
return 0.0;
|
||||
}
|
||||
self.levels[level][y * self.widths[level] + x]
|
||||
}
|
||||
|
||||
/// Get the resolution at a specific level.
|
||||
#[inline]
|
||||
pub(super) fn level_resolution(&self, level: usize) -> f32 {
|
||||
self.resolution * (1 << level) as f32
|
||||
}
|
||||
|
||||
/// Convert world point to grid coordinates at a specific level.
|
||||
#[inline]
|
||||
pub(super) fn world_to_level_grid(&self, point: WorldPoint, level: usize) -> GridCoord {
|
||||
let res = self.level_resolution(level);
|
||||
let x = ((point.x - self.origin.x) / res).floor() as i32;
|
||||
let y = ((point.y - self.origin.y) / res).floor() as i32;
|
||||
GridCoord::new(x, y)
|
||||
}
|
||||
|
||||
/// Score a scan at a given pose and level (upper bound for coarser levels).
|
||||
pub(super) fn score_scan_at_level(
|
||||
&self,
|
||||
points: &[(f32, f32)],
|
||||
pose: Pose2D,
|
||||
level: usize,
|
||||
sensor_offset: (f32, f32),
|
||||
) -> f32 {
|
||||
let cos_theta = pose.theta.cos();
|
||||
let sin_theta = pose.theta.sin();
|
||||
|
||||
// Sensor position in world frame
|
||||
let (offset_x, offset_y) = sensor_offset;
|
||||
let sensor_x = pose.x + offset_x * cos_theta - offset_y * sin_theta;
|
||||
let sensor_y = pose.y + offset_x * sin_theta + offset_y * cos_theta;
|
||||
|
||||
let mut score = 0.0f32;
|
||||
|
||||
for &(px, py) in points {
|
||||
// Transform point from sensor frame to world frame
|
||||
let world_x = sensor_x + px * cos_theta - py * sin_theta;
|
||||
let world_y = sensor_y + px * sin_theta + py * cos_theta;
|
||||
|
||||
let coord = self.world_to_level_grid(WorldPoint::new(world_x, world_y), level);
|
||||
score += self.get_score(level, coord.x, coord.y);
|
||||
}
|
||||
|
||||
score
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SIMD-OPTIMIZED METHODS
|
||||
// =========================================================================
|
||||
|
||||
/// Score a scan at a given pose and level using SIMD-optimized transforms.
|
||||
///
|
||||
/// Uses PointCloud (SoA format) and SIMD point transformation for efficiency.
|
||||
/// The final scoring is scalar (random grid access is not SIMD-friendly).
|
||||
pub fn score_scan_simd(
|
||||
&self,
|
||||
points: &PointCloud,
|
||||
pose: Pose2D,
|
||||
level: usize,
|
||||
sensor_offset: (f32, f32),
|
||||
) -> f32 {
|
||||
let n = points.len();
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Compute sensor pose in world frame
|
||||
let cos_t = pose.theta.cos();
|
||||
let sin_t = pose.theta.sin();
|
||||
let sensor_x = pose.x + sensor_offset.0 * cos_t - sensor_offset.1 * sin_t;
|
||||
let sensor_y = pose.y + sensor_offset.0 * sin_t + sensor_offset.1 * cos_t;
|
||||
let sensor_pose = Pose2D::new(sensor_x, sensor_y, pose.theta);
|
||||
|
||||
// Transform all points to world frame using SIMD
|
||||
let rot = RotationMatrix4::from_pose(&sensor_pose);
|
||||
let mut world_xs = vec![0.0f32; n];
|
||||
let mut world_ys = vec![0.0f32; n];
|
||||
transform_points_simd4(points, &rot, &mut world_xs, &mut world_ys);
|
||||
|
||||
// Convert to grid coordinates and sum scores
|
||||
let level_res = self.level_resolution(level);
|
||||
let inv_res = 1.0 / level_res;
|
||||
let width = self.widths[level] as i32;
|
||||
let height = self.heights[level] as i32;
|
||||
|
||||
let mut score = 0.0f32;
|
||||
for i in 0..n {
|
||||
let gx = ((world_xs[i] - self.origin.x) * inv_res).floor() as i32;
|
||||
let gy = ((world_ys[i] - self.origin.y) * inv_res).floor() as i32;
|
||||
|
||||
if gx >= 0 && gy >= 0 && gx < width && gy < height {
|
||||
score += self.levels[level][(gy as usize) * (width as usize) + (gx as usize)];
|
||||
}
|
||||
}
|
||||
|
||||
score
|
||||
}
|
||||
|
||||
/// Score scan with reusable scratch buffers to avoid allocations.
|
||||
///
|
||||
/// This is the most efficient version for repeated calls.
|
||||
pub fn score_scan_simd_with_scratch(
|
||||
&self,
|
||||
points: &PointCloud,
|
||||
pose: Pose2D,
|
||||
level: usize,
|
||||
sensor_offset: (f32, f32),
|
||||
world_xs: &mut Vec<f32>,
|
||||
world_ys: &mut Vec<f32>,
|
||||
) -> f32 {
|
||||
let n = points.len();
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Ensure scratch buffers are large enough
|
||||
world_xs.resize(n, 0.0);
|
||||
world_ys.resize(n, 0.0);
|
||||
|
||||
// Compute sensor pose in world frame
|
||||
let cos_t = pose.theta.cos();
|
||||
let sin_t = pose.theta.sin();
|
||||
let sensor_x = pose.x + sensor_offset.0 * cos_t - sensor_offset.1 * sin_t;
|
||||
let sensor_y = pose.y + sensor_offset.0 * sin_t + sensor_offset.1 * cos_t;
|
||||
let sensor_pose = Pose2D::new(sensor_x, sensor_y, pose.theta);
|
||||
|
||||
// Transform all points using SIMD
|
||||
let rot = RotationMatrix4::from_pose(&sensor_pose);
|
||||
transform_points_simd4(points, &rot, world_xs, world_ys);
|
||||
|
||||
// Convert to grid coordinates and sum scores
|
||||
let level_res = self.level_resolution(level);
|
||||
let inv_res = 1.0 / level_res;
|
||||
let width = self.widths[level] as i32;
|
||||
let height = self.heights[level] as i32;
|
||||
|
||||
let mut score = 0.0f32;
|
||||
for i in 0..n {
|
||||
let gx = ((world_xs[i] - self.origin.x) * inv_res).floor() as i32;
|
||||
let gy = ((world_ys[i] - self.origin.y) * inv_res).floor() as i32;
|
||||
|
||||
if gx >= 0 && gy >= 0 && gx < width && gy < height {
|
||||
score += self.levels[level][(gy as usize) * (width as usize) + (gx as usize)];
|
||||
}
|
||||
}
|
||||
|
||||
score
|
||||
}
|
||||
|
||||
/// Update level 0 from storage (for incremental updates).
|
||||
/// Call this when the storage has changed and you need fresh grids.
|
||||
pub fn update_from_storage(&mut self, storage: &GridStorage) {
|
||||
let width = storage.width();
|
||||
let height = storage.height();
|
||||
|
||||
// Only update if dimensions match
|
||||
if width != self.widths[0] || height != self.heights[0] {
|
||||
// Full rebuild needed
|
||||
*self = Self::from_storage(storage);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update level 0
|
||||
let sigma = self.resolution * 2.0;
|
||||
let two_sigma_sq = 2.0 * sigma * sigma;
|
||||
|
||||
self.levels[0].clear();
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let coord = GridCoord::new(x as i32, y as i32);
|
||||
let distance = storage.get_distance(coord);
|
||||
|
||||
let score = if distance < f32::MAX / 2.0 {
|
||||
(-distance * distance / two_sigma_sq).exp()
|
||||
} else {
|
||||
0.1
|
||||
};
|
||||
|
||||
self.levels[0].push(score);
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild coarser levels
|
||||
for level in 1..NUM_LEVELS {
|
||||
let prev_width = self.widths[level - 1];
|
||||
let prev_height = self.heights[level - 1];
|
||||
let new_width = self.widths[level];
|
||||
let new_height = self.heights[level];
|
||||
|
||||
for y in 0..new_height {
|
||||
for x in 0..new_width {
|
||||
let mut max_val = 0.0f32;
|
||||
|
||||
for dy in 0..2 {
|
||||
for dx in 0..2 {
|
||||
let px = x * 2 + dx;
|
||||
let py = y * 2 + dy;
|
||||
|
||||
if px < prev_width && py < prev_height {
|
||||
let idx = py * prev_width + px;
|
||||
max_val = max_val.max(self.levels[level - 1][idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.levels[level][y * new_width + x] = max_val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the dimensions at level 0.
|
||||
pub fn dimensions(&self) -> (usize, usize) {
|
||||
(self.widths[0], self.heights[0])
|
||||
}
|
||||
|
||||
/// Get the resolution at level 0.
|
||||
pub fn resolution(&self) -> f32 {
|
||||
self.resolution
|
||||
}
|
||||
|
||||
/// Get the origin.
|
||||
pub fn origin(&self) -> WorldPoint {
|
||||
self.origin
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Branch-and-bound scan matcher with precomputed multi-resolution grids.
|
||||
//!
|
||||
//! Implements Cartographer-style branch-and-bound search for efficient scan matching
|
||||
//! over large search windows. Uses a precomputed grid hierarchy where each coarse cell
|
||||
//! stores the maximum probability of all finer cells within it.
|
||||
//!
|
||||
//! This allows pruning: if the upper bound score at a coarse level is worse than
|
||||
//! the current best, the entire subtree can be skipped.
|
||||
//!
|
||||
//! ## Grid Hierarchy
|
||||
//!
|
||||
//! ```text
|
||||
//! Level 0 (1x): [0.8][0.2][0.9][0.1][0.7][0.3][0.8][0.2] <- original resolution
|
||||
//! \ / \ / \ / \ /
|
||||
//! Level 1 (4x): [0.8] [0.9] [0.7] [0.8] <- max of 4 cells
|
||||
//! \ / \ /
|
||||
//! Level 2 (16x): [0.9] [0.8]
|
||||
//! \ /
|
||||
//! Level 3 (64x): [0.9] <- coarsest level
|
||||
//! ```
|
||||
//!
|
||||
//! At each coarse level, the value is the maximum of all finer cells below.
|
||||
//! This provides an upper bound: the true score cannot exceed the coarse score.
|
||||
|
||||
mod algorithm;
|
||||
mod config;
|
||||
mod grids;
|
||||
mod search_node;
|
||||
|
||||
pub use algorithm::{
|
||||
branch_and_bound_match, branch_and_bound_match_scan, branch_and_bound_match_simd,
|
||||
};
|
||||
pub use config::{BranchBoundConfig, BranchBoundResult, NUM_LEVELS};
|
||||
pub use grids::PrecomputedGrids;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::{CellType, GridCoord, Pose2D};
|
||||
use crate::grid::GridStorage;
|
||||
|
||||
fn create_test_storage() -> GridStorage {
|
||||
let mut storage = GridStorage::centered(100, 100, 0.05);
|
||||
|
||||
// Create a wall at x=1m
|
||||
for y in 40..60 {
|
||||
storage.set_type(GridCoord::new(70, y), CellType::Wall);
|
||||
}
|
||||
|
||||
storage.recompute_distance_field();
|
||||
storage
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_precomputed_grids_creation() {
|
||||
let storage = create_test_storage();
|
||||
let grids = PrecomputedGrids::from_storage(&storage);
|
||||
|
||||
// Check dimensions at each level
|
||||
assert_eq!(grids.dimensions(), (100, 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_coarse_level_is_upper_bound() {
|
||||
let storage = create_test_storage();
|
||||
let grids = PrecomputedGrids::from_storage(&storage);
|
||||
|
||||
// Sample some random points and check that coarse scores >= fine scores
|
||||
let points = vec![(1.0, 0.0), (1.0, 0.1), (1.0, -0.1)];
|
||||
let pose = Pose2D::new(0.0, 0.0, 0.0);
|
||||
let offset = (0.0, 0.0);
|
||||
|
||||
let fine_score = grids.score_scan_at_level(&points, pose, 0, offset);
|
||||
|
||||
for level in 1..NUM_LEVELS {
|
||||
let coarse_score = grids.score_scan_at_level(&points, pose, level, offset);
|
||||
assert!(
|
||||
coarse_score >= fine_score - 0.01,
|
||||
"Coarse level {} score {} should be >= fine score {}",
|
||||
level,
|
||||
coarse_score,
|
||||
fine_score
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_branch_bound_match() {
|
||||
let storage = create_test_storage();
|
||||
let grids = PrecomputedGrids::from_storage(&storage);
|
||||
|
||||
// Create scan points that should match the wall at x=1m
|
||||
let points: Vec<(f32, f32)> = (-10..=10).map(|i| (1.0, i as f32 * 0.05)).collect();
|
||||
|
||||
let prior = Pose2D::new(0.0, 0.0, 0.0);
|
||||
let config = BranchBoundConfig {
|
||||
search_x: 0.5,
|
||||
search_y: 0.5,
|
||||
search_theta: 0.3,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = branch_and_bound_match(&points, &grids, prior, &config);
|
||||
|
||||
// Should find a match
|
||||
assert!(result.score > 0.0);
|
||||
assert!(result.nodes_expanded > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_points() {
|
||||
let storage = create_test_storage();
|
||||
let grids = PrecomputedGrids::from_storage(&storage);
|
||||
|
||||
let prior = Pose2D::new(0.0, 0.0, 0.0);
|
||||
let config = BranchBoundConfig::default();
|
||||
|
||||
let result = branch_and_bound_match(&[], &grids, prior, &config);
|
||||
|
||||
assert_eq!(result.score, 0.0);
|
||||
assert!(!result.converged);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Search node for branch-and-bound algorithm.
|
||||
|
||||
use crate::core::Pose2D;
|
||||
|
||||
use super::grids::PrecomputedGrids;
|
||||
|
||||
/// A node in the branch-and-bound search tree.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct SearchNode {
|
||||
/// Resolution level (0 = finest, NUM_LEVELS-1 = coarsest).
|
||||
pub level: usize,
|
||||
/// X position in grid coordinates at this level.
|
||||
pub x: i32,
|
||||
/// Y position in grid coordinates at this level.
|
||||
pub y: i32,
|
||||
/// Theta bin index (for angular discretization).
|
||||
pub theta_bin: i32,
|
||||
/// Upper bound score for this node.
|
||||
pub upper_bound: f32,
|
||||
}
|
||||
|
||||
impl SearchNode {
|
||||
/// Get the center pose for this search node.
|
||||
pub fn center_pose(&self, grids: &PrecomputedGrids, theta_step: f32) -> Pose2D {
|
||||
let res = grids.level_resolution(self.level);
|
||||
Pose2D::new(
|
||||
grids.origin().x + (self.x as f32 + 0.5) * res,
|
||||
grids.origin().y + (self.y as f32 + 0.5) * res,
|
||||
self.theta_bin as f32 * theta_step,
|
||||
)
|
||||
}
|
||||
|
||||
/// Generate child nodes (subdivide into finer level).
|
||||
pub fn subdivide(&self, grids: &PrecomputedGrids, num_theta_bins: i32) -> Vec<SearchNode> {
|
||||
if self.level == 0 {
|
||||
return Vec::new(); // Already at finest level
|
||||
}
|
||||
|
||||
let new_level = self.level - 1;
|
||||
let mut children = Vec::with_capacity(8); // 2x2x2 = 8 children
|
||||
|
||||
// Subdivide in x, y (each cell splits into 2x2)
|
||||
for dy in 0..2 {
|
||||
for dx in 0..2 {
|
||||
let new_x = self.x * 2 + dx;
|
||||
let new_y = self.y * 2 + dy;
|
||||
|
||||
// Check bounds
|
||||
if new_x < grids.widths[new_level] as i32 && new_y < grids.heights[new_level] as i32
|
||||
{
|
||||
// For theta, we can keep the same bin or split if needed
|
||||
// For simplicity, keep same theta bin at this level
|
||||
children.push(SearchNode {
|
||||
level: new_level,
|
||||
x: new_x,
|
||||
y: new_y,
|
||||
theta_bin: self.theta_bin,
|
||||
upper_bound: 0.0, // Will be computed by caller
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also subdivide angular bins if at coarse level
|
||||
if self.level >= 2 {
|
||||
// Add adjacent theta bins at the new level
|
||||
let base_children: Vec<_> = children.clone();
|
||||
for child in base_children {
|
||||
let theta_minus = (child.theta_bin - 1).rem_euclid(num_theta_bins);
|
||||
let theta_plus = (child.theta_bin + 1).rem_euclid(num_theta_bins);
|
||||
|
||||
children.push(SearchNode {
|
||||
theta_bin: theta_minus,
|
||||
..child.clone()
|
||||
});
|
||||
children.push(SearchNode {
|
||||
theta_bin: theta_plus,
|
||||
..child
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
children
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,19 @@
|
||||
//!
|
||||
//! Two-stage detection (like Cartographer):
|
||||
//! 1. Fast candidate detection using LiDAR-IRIS descriptors
|
||||
//! 2. Verification via correlative scan matching with Gauss-Newton refinement
|
||||
//! 2. Verification via scan matching (correlative or branch-and-bound)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::{LidarScan, Pose2D};
|
||||
use crate::core::simd::PointCloud;
|
||||
use crate::core::{LidarScan, Pose2D, normalize_angle};
|
||||
use crate::grid::GridStorage;
|
||||
|
||||
use super::descriptor::LidarIris;
|
||||
use crate::slam::{CorrelativeMatcher, CorrelativeMatcherConfig};
|
||||
use crate::slam::{
|
||||
BranchBoundConfig, CorrelativeMatcher, CorrelativeMatcherConfig, PrecomputedGrids,
|
||||
branch_and_bound_match_simd,
|
||||
};
|
||||
|
||||
/// Configuration for loop closure detection.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -57,6 +61,12 @@ pub struct LoopClosureConfig {
|
||||
/// Angular search window for verification (radians).
|
||||
#[serde(default = "default_verification_angular")]
|
||||
pub verification_angular_window: f32,
|
||||
|
||||
/// Use branch-and-bound matching instead of correlative matcher.
|
||||
/// More efficient for large search windows (>30cm).
|
||||
/// Enabled by default (Cartographer-style).
|
||||
#[serde(default = "default_true")]
|
||||
pub use_branch_bound: bool,
|
||||
}
|
||||
|
||||
fn default_keyframe_distance() -> f32 {
|
||||
@@ -107,6 +117,7 @@ impl Default for LoopClosureConfig {
|
||||
min_verification_score: default_min_verification_score(),
|
||||
verification_search_window: default_verification_search(),
|
||||
verification_angular_window: default_verification_angular(),
|
||||
use_branch_bound: true, // Cartographer-style branch-and-bound
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,10 +149,6 @@ struct Keyframe {
|
||||
pose: Pose2D,
|
||||
/// LiDAR-IRIS descriptor.
|
||||
descriptor: LidarIris,
|
||||
/// Stored scan for potential scan-to-scan verification.
|
||||
/// Currently using scan-to-map, but kept for future use.
|
||||
#[allow(dead_code)]
|
||||
scan: Option<LidarScan>,
|
||||
}
|
||||
|
||||
/// Result of loop closure verification.
|
||||
@@ -251,12 +258,11 @@ impl LoopClosureDetector {
|
||||
// Search for loop closure candidates
|
||||
let loop_closure = self.find_loop_closure(pose_idx, pose, &descriptor);
|
||||
|
||||
// Add as new keyframe (store scan for verification)
|
||||
// Add as new keyframe
|
||||
self.keyframes.push(Keyframe {
|
||||
pose_idx,
|
||||
pose,
|
||||
descriptor,
|
||||
scan: Some(scan.clone()),
|
||||
});
|
||||
self.last_keyframe_pose = Some(pose);
|
||||
|
||||
@@ -329,6 +335,89 @@ impl LoopClosureDetector {
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a loop closure candidate using branch-and-bound matching.
|
||||
///
|
||||
/// This variant uses precomputed multi-resolution grids for more efficient
|
||||
/// matching over larger search windows (Cartographer-style).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `candidate` - The loop closure candidate from `add_scan`
|
||||
/// * `current_scan` - The current lidar scan
|
||||
/// * `precomputed_grids` - Precomputed grids from finalized submap
|
||||
/// * `target_pose` - The pose of the target keyframe in world frame
|
||||
/// * `sensor_offset` - Lidar sensor offset (x, y) in robot frame
|
||||
///
|
||||
/// # Returns
|
||||
/// Verification result with refined pose and match score.
|
||||
pub fn verify_loop_closure_branch_bound(
|
||||
&self,
|
||||
candidate: &LoopClosure,
|
||||
current_scan: &LidarScan,
|
||||
precomputed_grids: &PrecomputedGrids,
|
||||
target_pose: Pose2D,
|
||||
sensor_offset: (f32, f32),
|
||||
) -> VerifiedLoopClosure {
|
||||
// Convert scan to PointCloud for SIMD-optimized matching
|
||||
let points = PointCloud::from_scan(current_scan);
|
||||
|
||||
// Use the candidate's estimated pose as prior
|
||||
let estimated_current_pose = Pose2D::new(
|
||||
target_pose.x - candidate.relative_pose.x,
|
||||
target_pose.y - candidate.relative_pose.y,
|
||||
normalize_angle(target_pose.theta - candidate.relative_pose.theta),
|
||||
);
|
||||
|
||||
// Configure branch-and-bound search
|
||||
let bb_config = BranchBoundConfig {
|
||||
search_x: self.config.verification_search_window,
|
||||
search_y: self.config.verification_search_window,
|
||||
search_theta: self.config.verification_angular_window,
|
||||
angular_resolution: 0.02, // ~1.15 degrees
|
||||
min_score: 0.0,
|
||||
sensor_offset,
|
||||
};
|
||||
|
||||
// Run branch-and-bound matching
|
||||
let result = branch_and_bound_match_simd(
|
||||
&points,
|
||||
precomputed_grids,
|
||||
estimated_current_pose,
|
||||
&bb_config,
|
||||
);
|
||||
|
||||
// Normalize score to 0.0-1.0 range (based on number of points)
|
||||
let max_possible_score = points.len() as f32;
|
||||
let normalized_score = if max_possible_score > 0.0 {
|
||||
result.score / max_possible_score
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Compute refined relative pose
|
||||
let refined_relative = Pose2D::new(
|
||||
target_pose.x - result.pose.x,
|
||||
target_pose.y - result.pose.y,
|
||||
normalize_angle(target_pose.theta - result.pose.theta),
|
||||
);
|
||||
|
||||
let verified = result.converged && normalized_score >= self.config.min_verification_score;
|
||||
|
||||
VerifiedLoopClosure {
|
||||
closure: candidate.clone(),
|
||||
refined_relative_pose: refined_relative,
|
||||
match_score: normalized_score,
|
||||
verified,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the target keyframe pose for a loop closure candidate.
|
||||
pub fn get_keyframe_pose(&self, pose_idx: usize) -> Option<Pose2D> {
|
||||
self.keyframes
|
||||
.iter()
|
||||
.find(|kf| kf.pose_idx == pose_idx)
|
||||
.map(|kf| kf.pose)
|
||||
}
|
||||
|
||||
/// Check if we should create a keyframe at current pose.
|
||||
fn should_create_keyframe(&self, pose: Pose2D) -> bool {
|
||||
match &self.last_keyframe_pose {
|
||||
@@ -413,17 +502,6 @@ impl LoopClosureDetector {
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize angle to [-π, π).
|
||||
fn normalize_angle(angle: f32) -> f32 {
|
||||
let mut a = angle % std::f32::consts::TAU;
|
||||
if a > std::f32::consts::PI {
|
||||
a -= std::f32::consts::TAU;
|
||||
} else if a < -std::f32::consts::PI {
|
||||
a += std::f32::consts::TAU;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::Pose2D;
|
||||
use crate::core::{Pose2D, normalize_angle};
|
||||
|
||||
use super::detector::LoopClosure;
|
||||
|
||||
@@ -435,17 +435,6 @@ fn huber_weight(residual: f32, delta: f32) -> f32 {
|
||||
if abs_r <= delta { 1.0 } else { delta / abs_r }
|
||||
}
|
||||
|
||||
/// Normalize angle to [-π, π).
|
||||
fn normalize_angle(angle: f32) -> f32 {
|
||||
let mut a = angle % std::f32::consts::TAU;
|
||||
if a > std::f32::consts::PI {
|
||||
a -= std::f32::consts::TAU;
|
||||
} else if a < -std::f32::consts::PI {
|
||||
a += std::f32::consts::TAU;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
//! Correlative scan-to-map matcher.
|
||||
//!
|
||||
//! Performs brute-force search over a 3D pose space (x, y, theta) to find
|
||||
//! the best alignment of a lidar scan to the current occupancy grid map.
|
||||
//! Correlative scan-to-map matcher implementation.
|
||||
|
||||
use crate::core::{CellType, LidarScan, Pose2D, WorldPoint};
|
||||
use crate::grid::GridStorage;
|
||||
|
||||
use crate::core::simd::{PointBatch, RotationMatrix4, transform_points_simd4, world_to_grid_simd4};
|
||||
use crate::core::simd::{PointCloud, RotationMatrix4, transform_points_simd4, world_to_grid_simd4};
|
||||
|
||||
use super::config::CorrelativeMatcherConfig;
|
||||
use super::types::ScanMatchResult;
|
||||
use super::helpers::{normalize_angle, solve_3x3};
|
||||
use super::lm::AdaptiveLM;
|
||||
use super::types::ScratchBuffers;
|
||||
use crate::slam::config::CorrelativeMatcherConfig;
|
||||
use crate::slam::types::ScanMatchResult;
|
||||
|
||||
/// Parameters for pose search space.
|
||||
struct SearchParams {
|
||||
/// Center pose for the search grid.
|
||||
center_pose: Pose2D,
|
||||
/// Prior pose for tie-breaking penalty.
|
||||
prior_pose: Pose2D,
|
||||
/// Linear step size in meters.
|
||||
linear_step: f32,
|
||||
/// Angular step size in radians.
|
||||
angular_step: f32,
|
||||
}
|
||||
|
||||
/// Correlative scan-to-map matcher.
|
||||
///
|
||||
@@ -19,40 +31,6 @@ pub struct CorrelativeMatcher {
|
||||
config: CorrelativeMatcherConfig,
|
||||
}
|
||||
|
||||
/// Scratch buffers for SIMD operations to avoid per-call allocations.
|
||||
pub struct ScratchBuffers {
|
||||
/// Transformed world X coordinates
|
||||
world_xs: Vec<f32>,
|
||||
/// Transformed world Y coordinates
|
||||
world_ys: Vec<f32>,
|
||||
/// Grid X coordinates
|
||||
grid_xs: Vec<i32>,
|
||||
/// Grid Y coordinates
|
||||
grid_ys: Vec<i32>,
|
||||
}
|
||||
|
||||
impl ScratchBuffers {
|
||||
/// Create scratch buffers for a given number of points.
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
world_xs: vec![0.0; capacity],
|
||||
world_ys: vec![0.0; capacity],
|
||||
grid_xs: vec![0; capacity],
|
||||
grid_ys: vec![0; capacity],
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize buffers if needed.
|
||||
pub fn resize(&mut self, n: usize) {
|
||||
if self.world_xs.len() < n {
|
||||
self.world_xs.resize(n, 0.0);
|
||||
self.world_ys.resize(n, 0.0);
|
||||
self.grid_xs.resize(n, 0);
|
||||
self.grid_ys.resize(n, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CorrelativeMatcher {
|
||||
/// Create a new correlative matcher.
|
||||
pub fn new(config: CorrelativeMatcherConfig) -> Self {
|
||||
@@ -185,13 +163,16 @@ impl CorrelativeMatcher {
|
||||
self.refine_with_gauss_newton(points, fine_result, storage)
|
||||
}
|
||||
|
||||
/// Refine a scan match result using Gauss-Newton optimization.
|
||||
/// Refine a scan match result using Gauss-Newton optimization with adaptive LM.
|
||||
///
|
||||
/// This provides sub-pixel/sub-degree accuracy by minimizing the sum of
|
||||
/// squared distances from scan points to the nearest walls.
|
||||
///
|
||||
/// Uses Levenberg-Marquardt damping for stability and a prior constraint
|
||||
/// (like Cartographer's CeresScanMatcher) to prevent over-correction.
|
||||
/// Uses **adaptive** Levenberg-Marquardt damping that adjusts based on step
|
||||
/// quality, providing faster convergence than fixed damping while maintaining
|
||||
/// stability. Also implements step acceptance/rejection logic.
|
||||
///
|
||||
/// Prior constraints (like Cartographer's CeresScanMatcher) prevent over-correction.
|
||||
fn refine_with_gauss_newton(
|
||||
&self,
|
||||
points: &[(f32, f32)],
|
||||
@@ -207,10 +188,22 @@ impl CorrelativeMatcher {
|
||||
let (offset_x, offset_y) = self.config.sensor_offset;
|
||||
|
||||
// Prior constraint weights (Cartographer-style)
|
||||
// These penalize deviation from the correlative search result
|
||||
let trans_weight = self.config.gn_translation_weight;
|
||||
let rot_weight = self.config.gn_rotation_weight;
|
||||
|
||||
// Adaptive LM optimizer
|
||||
let mut lm = AdaptiveLM::new();
|
||||
|
||||
// Compute initial cost
|
||||
let mut current_cost = self.compute_refinement_cost(
|
||||
points,
|
||||
pose,
|
||||
initial_pose,
|
||||
storage,
|
||||
trans_weight,
|
||||
rot_weight,
|
||||
);
|
||||
|
||||
for _iter in 0..self.config.gn_max_iterations {
|
||||
// Build normal equations: H * delta = -g
|
||||
// where H ≈ J^T * J, g = J^T * r
|
||||
@@ -239,10 +232,6 @@ impl CorrelativeMatcher {
|
||||
}
|
||||
|
||||
// Jacobian row: [∂d/∂x, ∂d/∂y, ∂d/∂θ]
|
||||
// ∂d/∂x = grad_x (direct translation effect)
|
||||
// ∂d/∂y = grad_y (direct translation effect)
|
||||
// ∂d/∂θ = grad_x * ∂world_x/∂θ + grad_y * ∂world_y/∂θ
|
||||
// = grad_x * (-px*sin - py*cos) + grad_y * (px*cos - py*sin)
|
||||
let dworld_x_dtheta = -px * sin_theta - py * cos_theta;
|
||||
let dworld_y_dtheta = px * cos_theta - py * sin_theta;
|
||||
let j_theta = grad_x * dworld_x_dtheta + grad_y * dworld_y_dtheta;
|
||||
@@ -250,7 +239,6 @@ impl CorrelativeMatcher {
|
||||
let j = [grad_x, grad_y, j_theta];
|
||||
|
||||
// Accumulate H = J^T * J and g = J^T * r
|
||||
// Using outer product: H += j * j^T, g += j * dist
|
||||
for i in 0..3 {
|
||||
g[i] += j[i] * dist;
|
||||
for k in 0..3 {
|
||||
@@ -260,9 +248,6 @@ impl CorrelativeMatcher {
|
||||
}
|
||||
|
||||
// Add prior constraint (Cartographer-style CeresScanMatcher)
|
||||
// This penalizes deviation from the correlative search result
|
||||
// E_prior = trans_weight * ||p - p_init||² + rot_weight * (θ - θ_init)²
|
||||
// Adds to H diagonal and g vector
|
||||
if trans_weight > 0.0 {
|
||||
h[0][0] += trans_weight;
|
||||
h[1][1] += trans_weight;
|
||||
@@ -275,10 +260,13 @@ impl CorrelativeMatcher {
|
||||
g[2] += rot_weight * angle_error;
|
||||
}
|
||||
|
||||
// Add Levenberg-Marquardt damping to diagonal
|
||||
let damping = self.config.gn_damping;
|
||||
for i in 0..3 {
|
||||
h[i][i] += damping * (1.0 + h[i][i]);
|
||||
// Store undamped Hessian for predicted reduction calculation
|
||||
let h_undamped = h;
|
||||
|
||||
// Add adaptive Levenberg-Marquardt damping to diagonal
|
||||
let damping = lm.damping();
|
||||
for (i, row) in h.iter_mut().enumerate() {
|
||||
row[i] += damping * (1.0 + row[i]);
|
||||
}
|
||||
|
||||
// Solve 3x3 system using Cramer's rule
|
||||
@@ -289,18 +277,69 @@ impl CorrelativeMatcher {
|
||||
break;
|
||||
}
|
||||
|
||||
// Update pose
|
||||
pose.x += delta[0];
|
||||
pose.y += delta[1];
|
||||
pose.theta += delta[2];
|
||||
// Compute predicted cost reduction using linear model
|
||||
// predicted_reduction = -delta^T * g - 0.5 * delta^T * H * delta
|
||||
let htd = [
|
||||
h_undamped[0][0] * delta[0]
|
||||
+ h_undamped[0][1] * delta[1]
|
||||
+ h_undamped[0][2] * delta[2],
|
||||
h_undamped[1][0] * delta[0]
|
||||
+ h_undamped[1][1] * delta[1]
|
||||
+ h_undamped[1][2] * delta[2],
|
||||
h_undamped[2][0] * delta[0]
|
||||
+ h_undamped[2][1] * delta[1]
|
||||
+ h_undamped[2][2] * delta[2],
|
||||
];
|
||||
let delta_dot_g = delta[0] * g[0] + delta[1] * g[1] + delta[2] * g[2];
|
||||
let delta_dot_htd = delta[0] * htd[0] + delta[1] * htd[1] + delta[2] * htd[2];
|
||||
let predicted_reduction = -delta_dot_g - 0.5 * delta_dot_htd;
|
||||
|
||||
// Normalize theta to [-π, π]
|
||||
pose.theta = normalize_angle(pose.theta);
|
||||
// Compute candidate pose
|
||||
let mut candidate = pose;
|
||||
candidate.x += delta[0];
|
||||
candidate.y += delta[1];
|
||||
candidate.theta = normalize_angle(candidate.theta + delta[2]);
|
||||
|
||||
// Check convergence
|
||||
let update_mag = (delta[0] * delta[0] + delta[1] * delta[1]).sqrt();
|
||||
if update_mag < self.config.gn_convergence_threshold {
|
||||
break;
|
||||
// Compute actual cost at candidate pose
|
||||
let candidate_cost = self.compute_refinement_cost(
|
||||
points,
|
||||
candidate,
|
||||
initial_pose,
|
||||
storage,
|
||||
trans_weight,
|
||||
rot_weight,
|
||||
);
|
||||
let actual_reduction = current_cost - candidate_cost;
|
||||
|
||||
// Compute step quality ratio (rho)
|
||||
let rho = if predicted_reduction.abs() > 1e-10 {
|
||||
actual_reduction / predicted_reduction
|
||||
} else if actual_reduction > 0.0 {
|
||||
1.0 // Tiny predicted but positive actual = good
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Accept or reject step based on actual improvement
|
||||
if actual_reduction > 0.0 {
|
||||
// Step improved cost - accept it
|
||||
pose = candidate;
|
||||
current_cost = candidate_cost;
|
||||
lm.update(rho);
|
||||
|
||||
// Check convergence
|
||||
let update_mag = (delta[0] * delta[0] + delta[1] * delta[1]).sqrt();
|
||||
if update_mag < self.config.gn_convergence_threshold {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Step made things worse - reject and increase damping
|
||||
lm.reject_step();
|
||||
|
||||
// If stuck at maximum damping, stop
|
||||
if lm.is_stuck() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,6 +354,54 @@ impl CorrelativeMatcher {
|
||||
ScanMatchResult::new(pose, normalized_score, hits, points.len())
|
||||
}
|
||||
|
||||
/// Compute the total cost for Gauss-Newton refinement.
|
||||
///
|
||||
/// Cost = sum of squared distances + prior constraint penalties.
|
||||
fn compute_refinement_cost(
|
||||
&self,
|
||||
points: &[(f32, f32)],
|
||||
pose: Pose2D,
|
||||
initial_pose: Pose2D,
|
||||
storage: &GridStorage,
|
||||
trans_weight: f32,
|
||||
rot_weight: f32,
|
||||
) -> f32 {
|
||||
let (offset_x, offset_y) = self.config.sensor_offset;
|
||||
let cos_theta = pose.theta.cos();
|
||||
let sin_theta = pose.theta.sin();
|
||||
|
||||
// Sensor position in world frame
|
||||
let sensor_x = pose.x + offset_x * cos_theta - offset_y * sin_theta;
|
||||
let sensor_y = pose.y + offset_x * sin_theta + offset_y * cos_theta;
|
||||
|
||||
let max_dist = storage.resolution() * 20.0;
|
||||
let mut cost = 0.0f32;
|
||||
|
||||
// Sum of squared distances
|
||||
for &(px, py) in points {
|
||||
let world_x = sensor_x + px * cos_theta - py * sin_theta;
|
||||
let world_y = sensor_y + px * sin_theta + py * cos_theta;
|
||||
|
||||
let (dist, _, _) = storage.get_distance_interpolated(WorldPoint::new(world_x, world_y));
|
||||
if dist < max_dist {
|
||||
cost += dist * dist;
|
||||
}
|
||||
}
|
||||
|
||||
// Prior constraint cost
|
||||
if trans_weight > 0.0 {
|
||||
let dx = pose.x - initial_pose.x;
|
||||
let dy = pose.y - initial_pose.y;
|
||||
cost += trans_weight * (dx * dx + dy * dy);
|
||||
}
|
||||
if rot_weight > 0.0 {
|
||||
let dtheta = normalize_angle(pose.theta - initial_pose.theta);
|
||||
cost += rot_weight * dtheta * dtheta;
|
||||
}
|
||||
|
||||
cost
|
||||
}
|
||||
|
||||
/// Search over pose space to find best match.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -383,7 +470,7 @@ impl CorrelativeMatcher {
|
||||
/// Score how well a pose aligns the scan points with the map.
|
||||
///
|
||||
/// Returns (raw_score, hit_count).
|
||||
fn score_pose(
|
||||
pub fn score_pose(
|
||||
&self,
|
||||
points: &[(f32, f32)],
|
||||
pose: Pose2D,
|
||||
@@ -496,7 +583,13 @@ impl CorrelativeMatcher {
|
||||
}
|
||||
|
||||
/// Extract valid points from scan in SoA format for SIMD processing.
|
||||
fn extract_scan_points_simd(&self, scan: &LidarScan) -> PointBatch {
|
||||
fn extract_scan_points_simd(&self, scan: &LidarScan) -> PointCloud {
|
||||
// Use PointCloud::from_scan for simple case (handles padding automatically)
|
||||
if self.config.max_points == 0 || scan.ranges.len() <= self.config.max_points {
|
||||
return PointCloud::from_scan(scan);
|
||||
}
|
||||
|
||||
// Manual extraction with subsampling
|
||||
let mut xs = Vec::with_capacity(scan.ranges.len());
|
||||
let mut ys = Vec::with_capacity(scan.ranges.len());
|
||||
|
||||
@@ -505,31 +598,33 @@ impl CorrelativeMatcher {
|
||||
ys.push(range * angle.sin());
|
||||
}
|
||||
|
||||
// Subsample if needed
|
||||
if self.config.max_points > 0 && xs.len() > self.config.max_points {
|
||||
let step = xs.len() / self.config.max_points;
|
||||
xs = xs.into_iter().step_by(step).collect();
|
||||
ys = ys.into_iter().step_by(step).collect();
|
||||
}
|
||||
// Subsample
|
||||
let step = xs.len() / self.config.max_points;
|
||||
xs = xs.into_iter().step_by(step).collect();
|
||||
ys = ys.into_iter().step_by(step).collect();
|
||||
|
||||
PointBatch { xs, ys }
|
||||
let mut points = PointCloud { xs, ys };
|
||||
points.pad_to_lanes(); // Ensure SIMD alignment
|
||||
points
|
||||
}
|
||||
|
||||
/// Single resolution search with SIMD.
|
||||
fn match_single_resolution_simd(
|
||||
&self,
|
||||
points: &PointBatch,
|
||||
points: &PointCloud,
|
||||
prior_pose: Pose2D,
|
||||
storage: &GridStorage,
|
||||
scratch: &mut ScratchBuffers,
|
||||
) -> ScanMatchResult {
|
||||
self.search_poses_simd(
|
||||
points,
|
||||
prior_pose,
|
||||
prior_pose, // center and prior are the same for single resolution
|
||||
SearchParams {
|
||||
center_pose: prior_pose,
|
||||
prior_pose, // center and prior are the same for single resolution
|
||||
linear_step: self.config.linear_resolution,
|
||||
angular_step: self.config.angular_resolution,
|
||||
},
|
||||
storage,
|
||||
self.config.linear_resolution,
|
||||
self.config.angular_resolution,
|
||||
scratch,
|
||||
)
|
||||
}
|
||||
@@ -537,7 +632,7 @@ impl CorrelativeMatcher {
|
||||
/// Multi-resolution search with SIMD: coarse first, then refine with Gauss-Newton.
|
||||
fn match_multi_resolution_simd(
|
||||
&self,
|
||||
points: &PointBatch,
|
||||
points: &PointCloud,
|
||||
prior_pose: Pose2D,
|
||||
storage: &GridStorage,
|
||||
scratch: &mut ScratchBuffers,
|
||||
@@ -548,11 +643,13 @@ impl CorrelativeMatcher {
|
||||
|
||||
let coarse_result = self.search_poses_simd(
|
||||
points,
|
||||
prior_pose,
|
||||
prior_pose,
|
||||
SearchParams {
|
||||
center_pose: prior_pose,
|
||||
prior_pose,
|
||||
linear_step: coarse_linear,
|
||||
angular_step: coarse_angular,
|
||||
},
|
||||
storage,
|
||||
coarse_linear,
|
||||
coarse_angular,
|
||||
scratch,
|
||||
);
|
||||
|
||||
@@ -572,16 +669,18 @@ impl CorrelativeMatcher {
|
||||
let fine_matcher = CorrelativeMatcher::new(fine_config);
|
||||
let fine_result = fine_matcher.search_poses_simd(
|
||||
points,
|
||||
coarse_result.pose, // center on coarse result
|
||||
prior_pose, // but penalize based on original prior
|
||||
SearchParams {
|
||||
center_pose: coarse_result.pose, // center on coarse result
|
||||
prior_pose, // but penalize based on original prior
|
||||
linear_step: self.config.linear_resolution,
|
||||
angular_step: self.config.angular_resolution,
|
||||
},
|
||||
storage,
|
||||
self.config.linear_resolution,
|
||||
self.config.angular_resolution,
|
||||
scratch,
|
||||
);
|
||||
|
||||
// Gauss-Newton refinement for sub-pixel accuracy
|
||||
// Convert PointBatch to Vec<(f32, f32)> for refinement
|
||||
// Convert PointCloud to Vec<(f32, f32)> for refinement
|
||||
let points_vec: Vec<(f32, f32)> = points
|
||||
.xs
|
||||
.iter()
|
||||
@@ -594,31 +693,28 @@ impl CorrelativeMatcher {
|
||||
/// Search over pose space to find best match using SIMD.
|
||||
fn search_poses_simd(
|
||||
&self,
|
||||
points: &PointBatch,
|
||||
center_pose: Pose2D,
|
||||
prior_pose: Pose2D,
|
||||
points: &PointCloud,
|
||||
params: SearchParams,
|
||||
storage: &GridStorage,
|
||||
linear_step: f32,
|
||||
angular_step: f32,
|
||||
scratch: &mut ScratchBuffers,
|
||||
) -> ScanMatchResult {
|
||||
let mut best_pose = center_pose;
|
||||
let mut best_pose = params.center_pose;
|
||||
let mut best_score = f32::NEG_INFINITY;
|
||||
let mut best_hits = 0usize;
|
||||
|
||||
// Generate search poses
|
||||
let x_steps = (self.config.search_x / linear_step).ceil() as i32;
|
||||
let y_steps = (self.config.search_y / linear_step).ceil() as i32;
|
||||
let theta_steps = (self.config.search_theta / angular_step).ceil() as i32;
|
||||
let x_steps = (self.config.search_x / params.linear_step).ceil() as i32;
|
||||
let y_steps = (self.config.search_y / params.linear_step).ceil() as i32;
|
||||
let theta_steps = (self.config.search_theta / params.angular_step).ceil() as i32;
|
||||
|
||||
for dx in -x_steps..=x_steps {
|
||||
let x = center_pose.x + dx as f32 * linear_step;
|
||||
let x = params.center_pose.x + dx as f32 * params.linear_step;
|
||||
|
||||
for dy in -y_steps..=y_steps {
|
||||
let y = center_pose.y + dy as f32 * linear_step;
|
||||
let y = params.center_pose.y + dy as f32 * params.linear_step;
|
||||
|
||||
for dtheta in -theta_steps..=theta_steps {
|
||||
let theta = center_pose.theta + dtheta as f32 * angular_step;
|
||||
let theta = params.center_pose.theta + dtheta as f32 * params.angular_step;
|
||||
let candidate = Pose2D::new(x, y, theta);
|
||||
|
||||
let (score, hits) = self.score_pose_simd(points, candidate, storage, scratch);
|
||||
@@ -627,10 +723,10 @@ impl CorrelativeMatcher {
|
||||
// This prefers poses closer to odometry when scores are similar
|
||||
// Position: Higher weight since encoders are typically reliable for distance
|
||||
// Angular: Lower weight to allow scan matching to correct heading drift
|
||||
let dist_to_prior = ((candidate.x - prior_pose.x).powi(2)
|
||||
+ (candidate.y - prior_pose.y).powi(2))
|
||||
let dist_to_prior = ((candidate.x - params.prior_pose.x).powi(2)
|
||||
+ (candidate.y - params.prior_pose.y).powi(2))
|
||||
.sqrt();
|
||||
let angle_dist = (candidate.theta - prior_pose.theta).abs();
|
||||
let angle_dist = (candidate.theta - params.prior_pose.theta).abs();
|
||||
let adjusted_score = score - dist_to_prior * 300.0 - angle_dist * 30.0;
|
||||
|
||||
if adjusted_score > best_score {
|
||||
@@ -656,9 +752,9 @@ impl CorrelativeMatcher {
|
||||
///
|
||||
/// Uses SIMD for batch point transformation and world-to-grid conversion.
|
||||
/// The final scoring loop is scalar due to random memory access patterns.
|
||||
fn score_pose_simd(
|
||||
pub fn score_pose_simd(
|
||||
&self,
|
||||
points: &PointBatch,
|
||||
points: &PointCloud,
|
||||
pose: Pose2D,
|
||||
storage: &GridStorage,
|
||||
scratch: &mut ScratchBuffers,
|
||||
@@ -758,255 +854,3 @@ impl CorrelativeMatcher {
|
||||
(score, hits)
|
||||
}
|
||||
}
|
||||
|
||||
/// Solve a 3x3 linear system Ax = b using Cramer's rule.
|
||||
///
|
||||
/// Returns [0, 0, 0] if the matrix is singular.
|
||||
fn solve_3x3(a: &[[f32; 3]; 3], b: &[f32; 3]) -> [f32; 3] {
|
||||
// Compute determinant of A
|
||||
let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
|
||||
- a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
|
||||
+ a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
|
||||
|
||||
if det.abs() < 1e-10 {
|
||||
return [0.0, 0.0, 0.0];
|
||||
}
|
||||
|
||||
let inv_det = 1.0 / det;
|
||||
|
||||
// Cramer's rule: x_i = det(A_i) / det(A)
|
||||
// where A_i is A with column i replaced by b
|
||||
let x0 = (b[0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
|
||||
- a[0][1] * (b[1] * a[2][2] - a[1][2] * b[2])
|
||||
+ a[0][2] * (b[1] * a[2][1] - a[1][1] * b[2]))
|
||||
* inv_det;
|
||||
|
||||
let x1 = (a[0][0] * (b[1] * a[2][2] - a[1][2] * b[2])
|
||||
- b[0] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
|
||||
+ a[0][2] * (a[1][0] * b[2] - b[1] * a[2][0]))
|
||||
* inv_det;
|
||||
|
||||
let x2 = (a[0][0] * (a[1][1] * b[2] - b[1] * a[2][1])
|
||||
- a[0][1] * (a[1][0] * b[2] - b[1] * a[2][0])
|
||||
+ b[0] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]))
|
||||
* inv_det;
|
||||
|
||||
[x0, x1, x2]
|
||||
}
|
||||
|
||||
/// Normalize angle to [-π, π).
|
||||
fn normalize_angle(angle: f32) -> f32 {
|
||||
let mut a = angle % std::f32::consts::TAU;
|
||||
if a > std::f32::consts::PI {
|
||||
a -= std::f32::consts::TAU;
|
||||
} else if a < -std::f32::consts::PI {
|
||||
a += std::f32::consts::TAU;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::GridCoord;
|
||||
|
||||
fn create_test_storage() -> GridStorage {
|
||||
GridStorage::centered(100, 100, 0.05) // 5m x 5m at 5cm resolution
|
||||
}
|
||||
|
||||
fn create_wall_in_front(storage: &mut GridStorage, pose: Pose2D, distance: f32) {
|
||||
// Create a wall in front of the pose
|
||||
let wall_x = pose.x + distance * pose.theta.cos();
|
||||
let wall_y = pose.y + distance * pose.theta.sin();
|
||||
|
||||
// Create a line of wall cells
|
||||
for offset in -10..=10 {
|
||||
let perp_angle = pose.theta + std::f32::consts::FRAC_PI_2;
|
||||
let wx = wall_x + offset as f32 * 0.05 * perp_angle.cos();
|
||||
let wy = wall_y + offset as f32 * 0.05 * perp_angle.sin();
|
||||
let coord = storage.world_to_grid(WorldPoint::new(wx, wy));
|
||||
if storage.is_valid_coord(coord) {
|
||||
storage.set_type(coord, CellType::Wall);
|
||||
}
|
||||
}
|
||||
|
||||
// Update distance field for Gaussian scoring
|
||||
storage.recompute_distance_field();
|
||||
}
|
||||
|
||||
fn create_simple_scan(distance: f32, num_points: usize) -> LidarScan {
|
||||
// Create a scan with points at uniform angles, all at same distance
|
||||
let angle_min = -std::f32::consts::PI;
|
||||
let angle_max = std::f32::consts::PI;
|
||||
let angle_increment = (angle_max - angle_min) / num_points as f32;
|
||||
|
||||
let ranges = vec![distance; num_points];
|
||||
let angles: Vec<f32> = (0..num_points)
|
||||
.map(|i| angle_min + i as f32 * angle_increment)
|
||||
.collect();
|
||||
|
||||
LidarScan::new(ranges, angles, 0.1, 10.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matcher_creation() {
|
||||
let matcher = CorrelativeMatcher::with_defaults();
|
||||
assert!(matcher.config().enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disabled_matcher() {
|
||||
let config = CorrelativeMatcherConfig::disabled();
|
||||
let matcher = CorrelativeMatcher::new(config);
|
||||
|
||||
let storage = create_test_storage();
|
||||
let scan = create_simple_scan(2.0, 360);
|
||||
let pose = Pose2D::new(0.0, 0.0, 0.0);
|
||||
|
||||
let result = matcher.match_scan(&scan, pose, &storage);
|
||||
|
||||
// Disabled matcher should return input pose unchanged
|
||||
assert!((result.pose.x - pose.x).abs() < 0.001);
|
||||
assert!((result.pose.y - pose.y).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matching_returns_result() {
|
||||
let mut storage = create_test_storage();
|
||||
let true_pose = Pose2D::new(0.0, 0.0, 0.0);
|
||||
|
||||
// Create a wall 2m in front
|
||||
create_wall_in_front(&mut storage, true_pose, 2.0);
|
||||
|
||||
// Use fast config for speed
|
||||
let config = CorrelativeMatcherConfig::fast();
|
||||
let matcher = CorrelativeMatcher::new(config);
|
||||
|
||||
// Test with prior very close to true pose
|
||||
let prior = Pose2D::new(0.01, 0.01, 0.01);
|
||||
|
||||
// Create a scan that simulates hitting the wall from the prior pose
|
||||
let angles: Vec<f32> = (-10..=10).map(|i| i as f32 * 0.05).collect();
|
||||
let ranges: Vec<f32> = vec![2.0; angles.len()];
|
||||
let scan = LidarScan::new(ranges, angles, 0.1, 10.0);
|
||||
|
||||
let result = matcher.match_scan(&scan, prior, &storage);
|
||||
|
||||
// Matcher should return a result (may or may not converge depending on map state)
|
||||
// At minimum, it should return a valid pose
|
||||
assert!(result.pose.x.is_finite());
|
||||
assert!(result.pose.y.is_finite());
|
||||
assert!(result.pose.theta.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_score_increases_with_hits() {
|
||||
let mut storage = create_test_storage();
|
||||
|
||||
// Fill some cells with walls
|
||||
for x in 40..60 {
|
||||
for y in 40..60 {
|
||||
storage.set_type(GridCoord::new(x, y), CellType::Wall);
|
||||
}
|
||||
}
|
||||
|
||||
// Update distance field for Gaussian scoring
|
||||
storage.recompute_distance_field();
|
||||
|
||||
let matcher = CorrelativeMatcher::with_defaults();
|
||||
let points: Vec<(f32, f32)> = vec![(0.0, 0.0), (0.05, 0.0), (0.1, 0.0)];
|
||||
|
||||
// Pose in wall area should score higher
|
||||
let pose_in_wall = Pose2D::new(0.0, 0.0, 0.0);
|
||||
let pose_in_floor = Pose2D::new(-2.0, -2.0, 0.0);
|
||||
|
||||
let (score_wall, hits_wall) = matcher.score_pose(&points, pose_in_wall, &storage);
|
||||
let (score_floor, hits_floor) = matcher.score_pose(&points, pose_in_floor, &storage);
|
||||
|
||||
assert!(hits_wall > hits_floor);
|
||||
assert!(score_wall > score_floor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simd_matches_scalar() {
|
||||
let mut storage = create_test_storage();
|
||||
let true_pose = Pose2D::new(0.0, 0.0, 0.0);
|
||||
|
||||
// Create a wall 2m in front
|
||||
create_wall_in_front(&mut storage, true_pose, 2.0);
|
||||
|
||||
// Use fast config for speed
|
||||
let config = CorrelativeMatcherConfig::fast();
|
||||
let matcher = CorrelativeMatcher::new(config);
|
||||
|
||||
// Create a scan
|
||||
let angles: Vec<f32> = (-10..=10).map(|i| i as f32 * 0.05).collect();
|
||||
let ranges: Vec<f32> = vec![2.0; angles.len()];
|
||||
let scan = LidarScan::new(ranges, angles, 0.1, 10.0);
|
||||
|
||||
let prior = Pose2D::new(0.01, 0.01, 0.01);
|
||||
|
||||
// Get scalar result
|
||||
let scalar_result = matcher.match_scan(&scan, prior, &storage);
|
||||
|
||||
// Get SIMD result
|
||||
let mut scratch = ScratchBuffers::new(scan.ranges.len());
|
||||
let simd_result = matcher.match_scan_simd(&scan, prior, &storage, &mut scratch);
|
||||
|
||||
// Results should match
|
||||
assert!((scalar_result.pose.x - simd_result.pose.x).abs() < 0.001);
|
||||
assert!((scalar_result.pose.y - simd_result.pose.y).abs() < 0.001);
|
||||
assert!((scalar_result.pose.theta - simd_result.pose.theta).abs() < 0.001);
|
||||
assert!((scalar_result.score - simd_result.score).abs() < 0.001);
|
||||
assert_eq!(scalar_result.hits, simd_result.hits);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simd_score_matches_scalar() {
|
||||
let mut storage = create_test_storage();
|
||||
|
||||
// Fill some cells with walls
|
||||
for x in 40..60 {
|
||||
for y in 40..60 {
|
||||
storage.set_type(GridCoord::new(x, y), CellType::Wall);
|
||||
}
|
||||
}
|
||||
|
||||
// Update distance field for Gaussian scoring
|
||||
storage.recompute_distance_field();
|
||||
|
||||
let matcher = CorrelativeMatcher::with_defaults();
|
||||
|
||||
// Create points in AoS format for scalar
|
||||
let points: Vec<(f32, f32)> = vec![(0.0, 0.0), (0.05, 0.0), (0.1, 0.0), (0.15, 0.0)];
|
||||
|
||||
// Convert to SoA format for SIMD
|
||||
let points_soa = PointBatch {
|
||||
xs: points.iter().map(|(x, _)| *x).collect(),
|
||||
ys: points.iter().map(|(_, y)| *y).collect(),
|
||||
};
|
||||
|
||||
let pose = Pose2D::new(0.0, 0.0, 0.0);
|
||||
let mut scratch = ScratchBuffers::new(points.len());
|
||||
|
||||
// Get scalar score
|
||||
let (scalar_score, scalar_hits) = matcher.score_pose(&points, pose, &storage);
|
||||
|
||||
// Get SIMD score
|
||||
let (simd_score, simd_hits) =
|
||||
matcher.score_pose_simd(&points_soa, pose, &storage, &mut scratch);
|
||||
|
||||
// Results should match
|
||||
assert!(
|
||||
(scalar_score - simd_score).abs() < 0.001,
|
||||
"score mismatch: {} vs {}",
|
||||
scalar_score,
|
||||
simd_score
|
||||
);
|
||||
assert_eq!(
|
||||
scalar_hits, simd_hits,
|
||||
"hits mismatch: {} vs {}",
|
||||
scalar_hits, simd_hits
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Helper functions for scan matching.
|
||||
|
||||
/// Solve a 3x3 linear system Ax = b using Cramer's rule.
|
||||
///
|
||||
/// Returns [0, 0, 0] if the matrix is singular.
|
||||
pub fn solve_3x3(a: &[[f32; 3]; 3], b: &[f32; 3]) -> [f32; 3] {
|
||||
// Compute determinant of A
|
||||
let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
|
||||
- a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
|
||||
+ a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
|
||||
|
||||
if det.abs() < 1e-10 {
|
||||
return [0.0, 0.0, 0.0];
|
||||
}
|
||||
|
||||
let inv_det = 1.0 / det;
|
||||
|
||||
// Cramer's rule: x_i = det(A_i) / det(A)
|
||||
// where A_i is A with column i replaced by b
|
||||
let x0 = (b[0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
|
||||
- a[0][1] * (b[1] * a[2][2] - a[1][2] * b[2])
|
||||
+ a[0][2] * (b[1] * a[2][1] - a[1][1] * b[2]))
|
||||
* inv_det;
|
||||
|
||||
let x1 = (a[0][0] * (b[1] * a[2][2] - a[1][2] * b[2])
|
||||
- b[0] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
|
||||
+ a[0][2] * (a[1][0] * b[2] - b[1] * a[2][0]))
|
||||
* inv_det;
|
||||
|
||||
let x2 = (a[0][0] * (a[1][1] * b[2] - b[1] * a[2][1])
|
||||
- a[0][1] * (a[1][0] * b[2] - b[1] * a[2][0])
|
||||
+ b[0] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]))
|
||||
* inv_det;
|
||||
|
||||
[x0, x1, x2]
|
||||
}
|
||||
|
||||
/// Normalize angle to [-π, π).
|
||||
pub fn normalize_angle(angle: f32) -> f32 {
|
||||
let mut a = angle % std::f32::consts::TAU;
|
||||
if a > std::f32::consts::PI {
|
||||
a -= std::f32::consts::TAU;
|
||||
} else if a < -std::f32::consts::PI {
|
||||
a += std::f32::consts::TAU;
|
||||
}
|
||||
a
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Adaptive Levenberg-Marquardt optimizer.
|
||||
|
||||
/// Adaptive Levenberg-Marquardt optimizer for Gauss-Newton refinement.
|
||||
///
|
||||
/// Automatically adjusts the damping parameter (lambda) based on step quality:
|
||||
/// - Good steps (actual reduction close to predicted): decrease lambda
|
||||
/// - Bad steps (actual reduction much less than predicted): increase lambda
|
||||
///
|
||||
/// This provides faster convergence than fixed damping while maintaining stability.
|
||||
pub(super) struct AdaptiveLM {
|
||||
/// Current damping parameter
|
||||
lambda: f32,
|
||||
/// Factor to increase/decrease lambda
|
||||
lambda_factor: f32,
|
||||
/// Minimum lambda value
|
||||
min_lambda: f32,
|
||||
/// Maximum lambda value
|
||||
max_lambda: f32,
|
||||
}
|
||||
|
||||
impl AdaptiveLM {
|
||||
/// Create a new adaptive LM optimizer with default parameters.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
lambda: 1e-3,
|
||||
lambda_factor: 10.0,
|
||||
min_lambda: 1e-7,
|
||||
max_lambda: 1e7,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current damping value.
|
||||
pub fn damping(&self) -> f32 {
|
||||
self.lambda
|
||||
}
|
||||
|
||||
/// Update lambda based on step quality.
|
||||
///
|
||||
/// `rho` is the ratio of actual cost reduction to predicted cost reduction.
|
||||
/// - rho > 0.75: Very good step, decrease lambda aggressively
|
||||
/// - rho > 0.25: Good step, decrease lambda
|
||||
/// - rho > 0.0: Acceptable step, keep lambda
|
||||
/// - rho <= 0.0: Bad step (cost increased), increase lambda
|
||||
pub fn update(&mut self, rho: f32) {
|
||||
if rho > 0.75 {
|
||||
// Very good step - decrease lambda
|
||||
self.lambda = (self.lambda / self.lambda_factor).max(self.min_lambda);
|
||||
} else if rho > 0.25 {
|
||||
// Good step - slightly decrease lambda
|
||||
self.lambda = (self.lambda / (self.lambda_factor.sqrt())).max(self.min_lambda);
|
||||
} else if rho < 0.0 {
|
||||
// Bad step - increase lambda significantly
|
||||
self.lambda = (self.lambda * self.lambda_factor).min(self.max_lambda);
|
||||
}
|
||||
// 0.0 <= rho <= 0.25: marginal step, keep lambda unchanged
|
||||
}
|
||||
|
||||
/// Reject a step (cost increased), increase lambda.
|
||||
pub fn reject_step(&mut self) {
|
||||
self.lambda = (self.lambda * self.lambda_factor).min(self.max_lambda);
|
||||
}
|
||||
|
||||
/// Check if optimizer has reached maximum damping (likely stuck).
|
||||
pub fn is_stuck(&self) -> bool {
|
||||
self.lambda >= self.max_lambda * 0.99
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Correlative scan-to-map matcher.
|
||||
//!
|
||||
//! Performs brute-force search over a 3D pose space (x, y, theta) to find
|
||||
//! the best alignment of a lidar scan to the current occupancy grid map.
|
||||
|
||||
mod core;
|
||||
mod helpers;
|
||||
mod lm;
|
||||
mod types;
|
||||
|
||||
pub use self::core::CorrelativeMatcher;
|
||||
pub use types::ScratchBuffers;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::simd::PointCloud;
|
||||
use crate::core::{CellType, GridCoord, LidarScan, Pose2D, WorldPoint};
|
||||
use crate::grid::GridStorage;
|
||||
use crate::slam::CorrelativeMatcherConfig;
|
||||
|
||||
fn create_test_storage() -> GridStorage {
|
||||
GridStorage::centered(100, 100, 0.05) // 5m x 5m at 5cm resolution
|
||||
}
|
||||
|
||||
fn create_wall_in_front(storage: &mut GridStorage, pose: Pose2D, distance: f32) {
|
||||
// Create a wall in front of the pose
|
||||
let wall_x = pose.x + distance * pose.theta.cos();
|
||||
let wall_y = pose.y + distance * pose.theta.sin();
|
||||
|
||||
// Create a line of wall cells
|
||||
for offset in -10..=10 {
|
||||
let perp_angle = pose.theta + std::f32::consts::FRAC_PI_2;
|
||||
let wx = wall_x + offset as f32 * 0.05 * perp_angle.cos();
|
||||
let wy = wall_y + offset as f32 * 0.05 * perp_angle.sin();
|
||||
let coord = storage.world_to_grid(WorldPoint::new(wx, wy));
|
||||
if storage.is_valid_coord(coord) {
|
||||
storage.set_type(coord, CellType::Wall);
|
||||
}
|
||||
}
|
||||
|
||||
// Update distance field for Gaussian scoring
|
||||
storage.recompute_distance_field();
|
||||
}
|
||||
|
||||
fn create_simple_scan(distance: f32, num_points: usize) -> LidarScan {
|
||||
// Create a scan with points at uniform angles, all at same distance
|
||||
let angle_min = -std::f32::consts::PI;
|
||||
let angle_max = std::f32::consts::PI;
|
||||
let angle_increment = (angle_max - angle_min) / num_points as f32;
|
||||
|
||||
let ranges = vec![distance; num_points];
|
||||
let angles: Vec<f32> = (0..num_points)
|
||||
.map(|i| angle_min + i as f32 * angle_increment)
|
||||
.collect();
|
||||
|
||||
LidarScan::new(ranges, angles, 0.1, 10.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matcher_creation() {
|
||||
let matcher = CorrelativeMatcher::with_defaults();
|
||||
assert!(matcher.config().enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disabled_matcher() {
|
||||
let config = CorrelativeMatcherConfig::disabled();
|
||||
let matcher = CorrelativeMatcher::new(config);
|
||||
|
||||
let storage = create_test_storage();
|
||||
let scan = create_simple_scan(2.0, 360);
|
||||
let pose = Pose2D::new(0.0, 0.0, 0.0);
|
||||
|
||||
let result = matcher.match_scan(&scan, pose, &storage);
|
||||
|
||||
// Disabled matcher should return input pose unchanged
|
||||
assert!((result.pose.x - pose.x).abs() < 0.001);
|
||||
assert!((result.pose.y - pose.y).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matching_returns_result() {
|
||||
let mut storage = create_test_storage();
|
||||
let true_pose = Pose2D::new(0.0, 0.0, 0.0);
|
||||
|
||||
// Create a wall 2m in front
|
||||
create_wall_in_front(&mut storage, true_pose, 2.0);
|
||||
|
||||
// Use fast config for speed
|
||||
let config = CorrelativeMatcherConfig::fast();
|
||||
let matcher = CorrelativeMatcher::new(config);
|
||||
|
||||
// Test with prior very close to true pose
|
||||
let prior = Pose2D::new(0.01, 0.01, 0.01);
|
||||
|
||||
// Create a scan that simulates hitting the wall from the prior pose
|
||||
let angles: Vec<f32> = (-10..=10).map(|i| i as f32 * 0.05).collect();
|
||||
let ranges: Vec<f32> = vec![2.0; angles.len()];
|
||||
let scan = LidarScan::new(ranges, angles, 0.1, 10.0);
|
||||
|
||||
let result = matcher.match_scan(&scan, prior, &storage);
|
||||
|
||||
// Matcher should return a result (may or may not converge depending on map state)
|
||||
// At minimum, it should return a valid pose
|
||||
assert!(result.pose.x.is_finite());
|
||||
assert!(result.pose.y.is_finite());
|
||||
assert!(result.pose.theta.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_score_increases_with_hits() {
|
||||
let mut storage = create_test_storage();
|
||||
|
||||
// Fill some cells with walls
|
||||
for x in 40..60 {
|
||||
for y in 40..60 {
|
||||
storage.set_type(GridCoord::new(x, y), CellType::Wall);
|
||||
}
|
||||
}
|
||||
|
||||
// Update distance field for Gaussian scoring
|
||||
storage.recompute_distance_field();
|
||||
|
||||
let matcher = CorrelativeMatcher::with_defaults();
|
||||
let points: Vec<(f32, f32)> = vec![(0.0, 0.0), (0.05, 0.0), (0.1, 0.0)];
|
||||
|
||||
// Pose in wall area should score higher
|
||||
let pose_in_wall = Pose2D::new(0.0, 0.0, 0.0);
|
||||
let pose_in_floor = Pose2D::new(-2.0, -2.0, 0.0);
|
||||
|
||||
let (score_wall, hits_wall) = matcher.score_pose(&points, pose_in_wall, &storage);
|
||||
let (score_floor, hits_floor) = matcher.score_pose(&points, pose_in_floor, &storage);
|
||||
|
||||
assert!(hits_wall > hits_floor);
|
||||
assert!(score_wall > score_floor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simd_matches_scalar() {
|
||||
let mut storage = create_test_storage();
|
||||
let true_pose = Pose2D::new(0.0, 0.0, 0.0);
|
||||
|
||||
// Create a wall 2m in front
|
||||
create_wall_in_front(&mut storage, true_pose, 2.0);
|
||||
|
||||
// Use fast config for speed
|
||||
let config = CorrelativeMatcherConfig::fast();
|
||||
let matcher = CorrelativeMatcher::new(config);
|
||||
|
||||
// Create a scan with LANES-aligned point count (20 = 5 * LANES)
|
||||
// to avoid padding differences between scalar and SIMD paths
|
||||
let angles: Vec<f32> = (-10..10).map(|i| i as f32 * 0.05).collect(); // 20 points
|
||||
let ranges: Vec<f32> = vec![2.0; angles.len()];
|
||||
let scan = LidarScan::new(ranges, angles, 0.1, 10.0);
|
||||
|
||||
let prior = Pose2D::new(0.01, 0.01, 0.01);
|
||||
|
||||
// Get scalar result
|
||||
let scalar_result = matcher.match_scan(&scan, prior, &storage);
|
||||
|
||||
// Get SIMD result
|
||||
let mut scratch = ScratchBuffers::new(scan.ranges.len());
|
||||
let simd_result = matcher.match_scan_simd(&scan, prior, &storage, &mut scratch);
|
||||
|
||||
// Poses should match closely
|
||||
assert!((scalar_result.pose.x - simd_result.pose.x).abs() < 0.001);
|
||||
assert!((scalar_result.pose.y - simd_result.pose.y).abs() < 0.001);
|
||||
assert!((scalar_result.pose.theta - simd_result.pose.theta).abs() < 0.001);
|
||||
// Score comparison: SIMD may have slightly different normalization due to internal padding
|
||||
assert!(
|
||||
(scalar_result.score - simd_result.score).abs() < 0.05,
|
||||
"score mismatch: scalar={}, simd={}",
|
||||
scalar_result.score,
|
||||
simd_result.score
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simd_score_matches_scalar() {
|
||||
let mut storage = create_test_storage();
|
||||
|
||||
// Fill some cells with walls
|
||||
for x in 40..60 {
|
||||
for y in 40..60 {
|
||||
storage.set_type(GridCoord::new(x, y), CellType::Wall);
|
||||
}
|
||||
}
|
||||
|
||||
// Update distance field for Gaussian scoring
|
||||
storage.recompute_distance_field();
|
||||
|
||||
let matcher = CorrelativeMatcher::with_defaults();
|
||||
|
||||
// Create points in AoS format for scalar
|
||||
let points: Vec<(f32, f32)> = vec![(0.0, 0.0), (0.05, 0.0), (0.1, 0.0), (0.15, 0.0)];
|
||||
|
||||
// Convert to SoA format for SIMD
|
||||
let points_soa = PointCloud {
|
||||
xs: points.iter().map(|(x, _)| *x).collect(),
|
||||
ys: points.iter().map(|(_, y)| *y).collect(),
|
||||
};
|
||||
|
||||
let pose = Pose2D::new(0.0, 0.0, 0.0);
|
||||
let mut scratch = ScratchBuffers::new(points.len());
|
||||
|
||||
// Get scalar score
|
||||
let (scalar_score, scalar_hits) = matcher.score_pose(&points, pose, &storage);
|
||||
|
||||
// Get SIMD score
|
||||
let (simd_score, simd_hits) =
|
||||
matcher.score_pose_simd(&points_soa, pose, &storage, &mut scratch);
|
||||
|
||||
// Results should match
|
||||
assert!(
|
||||
(scalar_score - simd_score).abs() < 0.001,
|
||||
"score mismatch: {} vs {}",
|
||||
scalar_score,
|
||||
simd_score
|
||||
);
|
||||
assert_eq!(
|
||||
scalar_hits, simd_hits,
|
||||
"hits mismatch: {} vs {}",
|
||||
scalar_hits, simd_hits
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Matcher types and scratch buffers.
|
||||
|
||||
/// Scratch buffers for SIMD operations to avoid per-call allocations.
|
||||
pub struct ScratchBuffers {
|
||||
/// Transformed world X coordinates
|
||||
pub world_xs: Vec<f32>,
|
||||
/// Transformed world Y coordinates
|
||||
pub world_ys: Vec<f32>,
|
||||
/// Grid X coordinates
|
||||
pub grid_xs: Vec<i32>,
|
||||
/// Grid Y coordinates
|
||||
pub grid_ys: Vec<i32>,
|
||||
}
|
||||
|
||||
impl ScratchBuffers {
|
||||
/// Create scratch buffers for a given number of points.
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
world_xs: vec![0.0; capacity],
|
||||
world_ys: vec![0.0; capacity],
|
||||
grid_xs: vec![0; capacity],
|
||||
grid_ys: vec![0; capacity],
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize buffers if needed.
|
||||
pub fn resize(&mut self, n: usize) {
|
||||
if self.world_xs.len() < n {
|
||||
self.world_xs.resize(n, 0.0);
|
||||
self.world_ys.resize(n, 0.0);
|
||||
self.grid_xs.resize(n, 0);
|
||||
self.grid_ys.resize(n, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,12 +25,23 @@
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
mod background_optimizer;
|
||||
mod branch_bound;
|
||||
mod config;
|
||||
mod matcher;
|
||||
mod types;
|
||||
|
||||
pub mod loop_closure;
|
||||
|
||||
pub use background_optimizer::{
|
||||
BackgroundOptimizer, BackgroundOptimizerConfig, OptimizationResult, OptimizerTrigger,
|
||||
PoseCorrection,
|
||||
};
|
||||
pub use branch_bound::{
|
||||
BranchBoundConfig, BranchBoundResult, NUM_LEVELS, PrecomputedGrids, branch_and_bound_match,
|
||||
branch_and_bound_match_scan, branch_and_bound_match_simd,
|
||||
};
|
||||
pub use config::CorrelativeMatcherConfig;
|
||||
pub use matcher::CorrelativeMatcher;
|
||||
pub use loop_closure::{LoopClosureConfig, LoopClosureDetector, PoseGraphConfig};
|
||||
pub use matcher::{CorrelativeMatcher, ScratchBuffers};
|
||||
pub use types::{MatchQuality, ScanMatchResult};
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::Pose2D;
|
||||
use crate::core::{Pose2D, normalize_angle};
|
||||
|
||||
use super::manager::SubmapCorrection;
|
||||
use super::types::SubmapId;
|
||||
@@ -174,12 +174,6 @@ impl SubmapPoseGraph {
|
||||
self.nodes.iter().find(|n| n.id == id)
|
||||
}
|
||||
|
||||
/// Get node index by submap ID.
|
||||
#[allow(dead_code)]
|
||||
fn node_index(&self, id: SubmapId) -> Option<usize> {
|
||||
self.nodes.iter().position(|n| n.id == id)
|
||||
}
|
||||
|
||||
/// Add a new submap node.
|
||||
///
|
||||
/// If this is not the first node, also creates an odometry edge
|
||||
@@ -443,17 +437,6 @@ fn huber_weight(residual: f32, delta: f32) -> f32 {
|
||||
if abs_r <= delta { 1.0 } else { delta / abs_r }
|
||||
}
|
||||
|
||||
/// Normalize angle to [-π, π).
|
||||
fn normalize_angle(angle: f32) -> f32 {
|
||||
let mut a = angle % std::f32::consts::TAU;
|
||||
if a > std::f32::consts::PI {
|
||||
a -= std::f32::consts::TAU;
|
||||
} else if a < -std::f32::consts::PI {
|
||||
a += std::f32::consts::TAU;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -57,33 +57,58 @@ impl MultiSubmapMatchResult {
|
||||
}
|
||||
|
||||
/// Configuration for multi-submap matching.
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct MultiSubmapMatchConfig {
|
||||
/// Maximum distance to consider overlapping submaps.
|
||||
#[serde(default = "default_max_overlap_distance")]
|
||||
pub max_overlap_distance: f32,
|
||||
|
||||
/// Include active submap in search.
|
||||
#[serde(default = "default_include_active")]
|
||||
pub include_active: bool,
|
||||
|
||||
/// Include finalized submaps in search.
|
||||
#[serde(default = "default_include_finalized")]
|
||||
pub include_finalized: bool,
|
||||
|
||||
/// Maximum number of submaps to search.
|
||||
#[serde(default = "default_max_submaps")]
|
||||
pub max_submaps: usize,
|
||||
|
||||
/// Minimum score improvement to prefer a different submap.
|
||||
/// Prevents jumping between submaps with similar scores.
|
||||
#[serde(default = "default_score_hysteresis")]
|
||||
pub score_hysteresis: f32,
|
||||
}
|
||||
|
||||
fn default_max_overlap_distance() -> f32 {
|
||||
3.0
|
||||
}
|
||||
|
||||
fn default_include_active() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_include_finalized() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_max_submaps() -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn default_score_hysteresis() -> f32 {
|
||||
0.05
|
||||
}
|
||||
|
||||
impl Default for MultiSubmapMatchConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_overlap_distance: 3.0, // 3m
|
||||
include_active: true,
|
||||
include_finalized: true,
|
||||
max_submaps: 5,
|
||||
score_hysteresis: 0.05,
|
||||
max_overlap_distance: default_max_overlap_distance(),
|
||||
include_active: default_include_active(),
|
||||
include_finalized: default_include_finalized(),
|
||||
max_submaps: default_max_submaps(),
|
||||
score_hysteresis: default_score_hysteresis(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use crate::core::{LidarScan, Pose2D, WorldPoint};
|
||||
use crate::grid::GridStorage;
|
||||
use crate::slam::PrecomputedGrids;
|
||||
use std::cell::RefCell;
|
||||
|
||||
/// Unique identifier for a submap.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
@@ -134,6 +136,10 @@ pub struct Submap {
|
||||
|
||||
/// Last pose in local frame (for distance tracking).
|
||||
last_local_pose: Option<Pose2D>,
|
||||
|
||||
/// Cached precomputed multi-resolution grids for branch-and-bound matching.
|
||||
/// Lazily initialized when first needed, invalidated on grid regeneration.
|
||||
precomputed_cache: RefCell<Option<PrecomputedGrids>>,
|
||||
}
|
||||
|
||||
impl Submap {
|
||||
@@ -156,6 +162,7 @@ impl Submap {
|
||||
overlap_count: 0,
|
||||
distance_traveled: 0.0,
|
||||
last_local_pose: None,
|
||||
precomputed_cache: RefCell::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,17 +240,58 @@ impl Submap {
|
||||
}
|
||||
|
||||
/// Finalize the submap - no more updates allowed.
|
||||
/// Also builds the precomputed grids cache for efficient matching.
|
||||
pub fn finalize(&mut self) {
|
||||
self.state = SubmapState::Finalized;
|
||||
// Pre-build the cache since finalized submaps are used for matching
|
||||
self.build_precomputed_grids();
|
||||
}
|
||||
|
||||
/// Get the precomputed multi-resolution grids for branch-and-bound matching.
|
||||
/// Lazily computes and caches the grids on first access.
|
||||
/// Only useful for finalized submaps.
|
||||
pub fn precomputed_grids(&self) -> std::cell::Ref<'_, PrecomputedGrids> {
|
||||
// Ensure cache is populated
|
||||
{
|
||||
let mut cache = self.precomputed_cache.borrow_mut();
|
||||
if cache.is_none() {
|
||||
*cache = Some(PrecomputedGrids::from_storage(&self.grid));
|
||||
}
|
||||
}
|
||||
std::cell::Ref::map(self.precomputed_cache.borrow(), |opt| {
|
||||
opt.as_ref().expect("Cache should be populated")
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if precomputed grids are already cached.
|
||||
#[inline]
|
||||
pub fn has_precomputed_grids(&self) -> bool {
|
||||
self.precomputed_cache.borrow().is_some()
|
||||
}
|
||||
|
||||
/// Force rebuild of precomputed grids cache.
|
||||
/// Call this after finalization or after origin adjustment.
|
||||
pub fn build_precomputed_grids(&mut self) {
|
||||
*self.precomputed_cache.borrow_mut() = Some(PrecomputedGrids::from_storage(&self.grid));
|
||||
}
|
||||
|
||||
/// Invalidate the precomputed grids cache.
|
||||
/// Called when the grid is regenerated.
|
||||
fn invalidate_precomputed_cache(&mut self) {
|
||||
*self.precomputed_cache.borrow_mut() = None;
|
||||
}
|
||||
|
||||
/// Regenerate the local grid from stored scans.
|
||||
/// Called after the submap origin has been adjusted.
|
||||
/// Invalidates and rebuilds the precomputed grids cache if finalized.
|
||||
pub fn regenerate_grid(
|
||||
&mut self,
|
||||
sensor_config: &crate::grid::SensorConfig,
|
||||
grid_config: &crate::grid::GridConfig,
|
||||
) {
|
||||
// Invalidate the cache since grid is changing
|
||||
self.invalidate_precomputed_cache();
|
||||
|
||||
// Clear the grid
|
||||
self.grid.clear();
|
||||
|
||||
@@ -257,6 +305,11 @@ impl Submap {
|
||||
grid_config,
|
||||
);
|
||||
}
|
||||
|
||||
// Rebuild cache if this is a finalized submap
|
||||
if self.is_finalized() {
|
||||
self.build_precomputed_grids();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the bounds of this submap in world coordinates.
|
||||
|
||||
Reference in New Issue
Block a user