Skip to main content

rust_robotics_planning/
fringe_search.rs

1//! Fringe Search path planning algorithm.
2//!
3//! Fringe Search replaces the priority queue of A* with iteratively
4//! increasing `f`-cost thresholds over two fringe lists. It keeps the same
5//! admissible Euclidean heuristic as A* while avoiding heap maintenance.
6
7use std::collections::{HashMap, VecDeque};
8
9use crate::grid::{get_motion_model_8, GridMap, Node};
10use rust_robotics_core::{Obstacles, Path2D, PathPlanner, Point2D, RoboticsError, RoboticsResult};
11
12/// Configuration for Fringe Search.
13#[derive(Debug, Clone)]
14pub struct FringeSearchConfig {
15    /// Grid resolution in meters.
16    pub resolution: f64,
17    /// Robot radius for obstacle inflation.
18    pub robot_radius: f64,
19    /// Heuristic weight (1.0 = admissible, >1.0 = weighted search).
20    pub heuristic_weight: f64,
21}
22
23impl Default for FringeSearchConfig {
24    fn default() -> Self {
25        Self {
26            resolution: 1.0,
27            robot_radius: 0.5,
28            heuristic_weight: 1.0,
29        }
30    }
31}
32
33impl FringeSearchConfig {
34    pub fn validate(&self) -> RoboticsResult<()> {
35        if !self.resolution.is_finite() || self.resolution <= 0.0 {
36            return Err(RoboticsError::InvalidParameter(format!(
37                "resolution must be positive and finite, got {}",
38                self.resolution
39            )));
40        }
41        if !self.robot_radius.is_finite() || self.robot_radius < 0.0 {
42            return Err(RoboticsError::InvalidParameter(format!(
43                "robot_radius must be non-negative and finite, got {}",
44                self.robot_radius
45            )));
46        }
47        if !self.heuristic_weight.is_finite() || self.heuristic_weight <= 0.0 {
48            return Err(RoboticsError::InvalidParameter(format!(
49                "heuristic_weight must be positive and finite, got {}",
50                self.heuristic_weight
51            )));
52        }
53
54        Ok(())
55    }
56}
57
58/// Fringe Search planner on an 8-connected occupancy grid.
59#[derive(Debug)]
60pub struct FringeSearchPlanner {
61    grid_map: GridMap,
62    config: FringeSearchConfig,
63    motion: Vec<(i32, i32, f64)>,
64}
65
66/// Search-effort statistics collected during one Fringe Search query.
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
68pub struct FringeSearchStats {
69    pub threshold_iterations: usize,
70    pub expanded_nodes: usize,
71    pub generated_nodes: usize,
72    pub deferred_nodes: usize,
73    pub stale_nodes: usize,
74    pub max_active_nodes: usize,
75}
76
77impl FringeSearchPlanner {
78    /// Create a new planner from obstacle x/y vectors.
79    pub fn new(ox: &[f64], oy: &[f64], config: FringeSearchConfig) -> Self {
80        Self::try_new(ox, oy, config).expect(
81            "invalid Fringe Search planner input: obstacle list must be non-empty and valid, and config values must be positive/finite",
82        )
83    }
84
85    /// Create a validated planner from obstacle x/y vectors.
86    pub fn try_new(ox: &[f64], oy: &[f64], config: FringeSearchConfig) -> RoboticsResult<Self> {
87        config.validate()?;
88        let grid_map = GridMap::try_new(ox, oy, config.resolution, config.robot_radius)?;
89
90        Ok(Self {
91            grid_map,
92            config,
93            motion: get_motion_model_8(),
94        })
95    }
96
97    /// Create from obstacle x/y vectors with explicit scalar parameters.
98    pub fn from_obstacles(ox: &[f64], oy: &[f64], resolution: f64, robot_radius: f64) -> Self {
99        let config = FringeSearchConfig {
100            resolution,
101            robot_radius,
102            ..Default::default()
103        };
104        Self::new(ox, oy, config)
105    }
106
107    /// Create from typed obstacle points.
108    pub fn from_obstacle_points(
109        obstacles: &Obstacles,
110        config: FringeSearchConfig,
111    ) -> RoboticsResult<Self> {
112        config.validate()?;
113        let grid_map = GridMap::from_obstacles(obstacles, config.resolution, config.robot_radius)?;
114
115        Ok(Self {
116            grid_map,
117            config,
118            motion: get_motion_model_8(),
119        })
120    }
121
122    /// Plan without importing the trait explicitly.
123    pub fn plan(&self, start: Point2D, goal: Point2D) -> RoboticsResult<Path2D> {
124        self.plan_impl(start, goal).map(|(path, _stats)| path)
125    }
126
127    /// Plan from raw coordinates without importing the trait explicitly.
128    pub fn plan_xy(&self, sx: f64, sy: f64, gx: f64, gy: f64) -> RoboticsResult<Path2D> {
129        self.plan_impl(Point2D::new(sx, sy), Point2D::new(gx, gy))
130            .map(|(path, _stats)| path)
131    }
132
133    /// Plan a path and return per-query search-effort statistics.
134    pub fn plan_with_stats(
135        &self,
136        start: Point2D,
137        goal: Point2D,
138    ) -> RoboticsResult<(Path2D, FringeSearchStats)> {
139        self.plan_impl(start, goal)
140    }
141
142    pub fn grid_map(&self) -> &GridMap {
143        &self.grid_map
144    }
145
146    fn calc_heuristic(&self, x: i32, y: i32, goal_x: i32, goal_y: i32) -> f64 {
147        self.config.heuristic_weight * (((x - goal_x).pow(2) + (y - goal_y).pow(2)) as f64).sqrt()
148    }
149
150    fn build_path(&self, goal_index: usize, node_storage: &[Node]) -> Path2D {
151        let mut points = Vec::new();
152        let mut current_index = Some(goal_index);
153
154        while let Some(index) = current_index {
155            let node = &node_storage[index];
156            points.push(Point2D::new(
157                self.grid_map.calc_x_position(node.x),
158                self.grid_map.calc_y_position(node.y),
159            ));
160            current_index = node.parent_index;
161        }
162
163        points.reverse();
164        Path2D::from_points(points)
165    }
166
167    fn ensure_query_is_valid(&self, x: i32, y: i32, label: &str) -> RoboticsResult<()> {
168        if self.grid_map.is_valid(x, y) {
169            return Ok(());
170        }
171
172        Err(RoboticsError::PlanningError(format!(
173            "{} position is invalid",
174            label
175        )))
176    }
177
178    fn plan_impl(
179        &self,
180        start: Point2D,
181        goal: Point2D,
182    ) -> RoboticsResult<(Path2D, FringeSearchStats)> {
183        let start_x = self.grid_map.calc_x_index(start.x);
184        let start_y = self.grid_map.calc_y_index(start.y);
185        let goal_x = self.grid_map.calc_x_index(goal.x);
186        let goal_y = self.grid_map.calc_y_index(goal.y);
187
188        self.ensure_query_is_valid(start_x, start_y, "Start")?;
189        self.ensure_query_is_valid(goal_x, goal_y, "Goal")?;
190
191        let mut node_storage = vec![Node::new(start_x, start_y, 0.0, None)];
192        let start_grid_index = self.grid_map.calc_index(start_x, start_y);
193
194        let mut best_cost_by_grid = HashMap::new();
195        best_cost_by_grid.insert(start_grid_index, 0.0);
196
197        let mut current_fringe = VecDeque::new();
198        current_fringe.push_back(0usize);
199
200        let mut f_limit = self.calc_heuristic(start_x, start_y, goal_x, goal_y);
201        let mut stats = FringeSearchStats {
202            max_active_nodes: current_fringe.len(),
203            ..Default::default()
204        };
205
206        while !current_fringe.is_empty() {
207            stats.threshold_iterations += 1;
208            let mut next_fringe = VecDeque::new();
209            let mut min_exceeded = f64::INFINITY;
210
211            while let Some(current_index) = current_fringe.pop_front() {
212                let current_node = node_storage[current_index].clone();
213                let current_grid_index = self.grid_map.calc_index(current_node.x, current_node.y);
214
215                let Some(&best_known_cost) = best_cost_by_grid.get(&current_grid_index) else {
216                    continue;
217                };
218                if current_node.cost > best_known_cost + 1e-9 {
219                    stats.stale_nodes += 1;
220                    continue;
221                }
222
223                let f_cost = current_node.cost
224                    + self.calc_heuristic(current_node.x, current_node.y, goal_x, goal_y);
225                if f_cost > f_limit {
226                    stats.deferred_nodes += 1;
227                    min_exceeded = min_exceeded.min(f_cost);
228                    next_fringe.push_back(current_index);
229                    stats.max_active_nodes = stats
230                        .max_active_nodes
231                        .max(current_fringe.len() + next_fringe.len());
232                    continue;
233                }
234
235                stats.expanded_nodes += 1;
236
237                if current_node.x == goal_x && current_node.y == goal_y {
238                    return Ok((self.build_path(current_index, &node_storage), stats));
239                }
240
241                for &(dx, dy, move_cost) in &self.motion {
242                    if !self
243                        .grid_map
244                        .is_valid_offset(current_node.x, current_node.y, dx, dy)
245                    {
246                        continue;
247                    }
248
249                    let next_x = current_node.x + dx;
250                    let next_y = current_node.y + dy;
251                    let next_grid_index = self.grid_map.calc_index(next_x, next_y);
252                    let next_cost = current_node.cost + move_cost;
253
254                    if best_cost_by_grid
255                        .get(&next_grid_index)
256                        .is_some_and(|&known_cost| next_cost >= known_cost - 1e-9)
257                    {
258                        continue;
259                    }
260
261                    node_storage.push(Node::new(next_x, next_y, next_cost, Some(current_index)));
262                    let next_index = node_storage.len() - 1;
263                    best_cost_by_grid.insert(next_grid_index, next_cost);
264                    stats.generated_nodes += 1;
265
266                    let next_f_cost =
267                        next_cost + self.calc_heuristic(next_x, next_y, goal_x, goal_y);
268                    if next_f_cost <= f_limit {
269                        current_fringe.push_back(next_index);
270                    } else {
271                        min_exceeded = min_exceeded.min(next_f_cost);
272                        next_fringe.push_back(next_index);
273                    }
274                    stats.max_active_nodes = stats
275                        .max_active_nodes
276                        .max(current_fringe.len() + next_fringe.len());
277                }
278            }
279
280            if next_fringe.is_empty() || !min_exceeded.is_finite() {
281                break;
282            }
283
284            current_fringe = next_fringe;
285            f_limit = min_exceeded;
286        }
287
288        Err(RoboticsError::PlanningError("No path found".to_string()))
289    }
290}
291
292impl PathPlanner for FringeSearchPlanner {
293    fn plan(&self, start: Point2D, goal: Point2D) -> Result<Path2D, RoboticsError> {
294        self.plan_impl(start, goal).map(|(path, _stats)| path)
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::a_star::{AStarConfig, AStarPlanner};
302    use crate::moving_ai::{MovingAiMap, MovingAiScenario};
303
304    fn create_simple_obstacles() -> (Vec<f64>, Vec<f64>) {
305        let mut ox = Vec::new();
306        let mut oy = Vec::new();
307
308        for i in 0..11 {
309            ox.push(i as f64);
310            oy.push(0.0);
311            ox.push(i as f64);
312            oy.push(10.0);
313            ox.push(0.0);
314            oy.push(i as f64);
315            ox.push(10.0);
316            oy.push(i as f64);
317        }
318
319        for i in 4..7 {
320            ox.push(5.0);
321            oy.push(i as f64);
322        }
323
324        (ox, oy)
325    }
326
327    #[test]
328    fn test_fringe_search_finds_path() {
329        let (ox, oy) = create_simple_obstacles();
330        let planner = FringeSearchPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
331
332        let path = planner
333            .plan(Point2D::new(2.0, 2.0), Point2D::new(8.0, 8.0))
334            .expect("Fringe Search should find a path on the simple map");
335
336        assert!(!path.is_empty());
337    }
338
339    #[test]
340    fn test_fringe_search_try_new_rejects_invalid_config() {
341        let (ox, oy) = create_simple_obstacles();
342        let config = FringeSearchConfig {
343            heuristic_weight: 0.0,
344            ..Default::default()
345        };
346
347        let err = FringeSearchPlanner::try_new(&ox, &oy, config)
348            .expect_err("invalid heuristic weight should be rejected");
349        assert!(matches!(err, RoboticsError::InvalidParameter(_)));
350    }
351
352    #[test]
353    fn test_fringe_search_matches_simple_map_length() {
354        let (ox, oy) = create_simple_obstacles();
355        let planner = FringeSearchPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
356        let a_star = AStarPlanner::new(
357            &ox,
358            &oy,
359            AStarConfig {
360                resolution: 1.0,
361                robot_radius: 0.5,
362                heuristic_weight: 1.0,
363            },
364        );
365
366        let path = planner
367            .plan(Point2D::new(2.0, 2.0), Point2D::new(8.0, 8.0))
368            .expect("Fringe Search should find a path on the simple map");
369        let reference = a_star
370            .plan(Point2D::new(2.0, 2.0), Point2D::new(8.0, 8.0))
371            .expect("A* should find the same reference path on the simple map");
372
373        assert!(
374            (path.total_length() - reference.total_length()).abs() < 1e-6,
375            "simple-map path length {} should match A* reference length {}",
376            path.total_length(),
377            reference.total_length()
378        );
379    }
380
381    #[test]
382    #[ignore = "long-running MovingAI benchmark"]
383    fn test_fringe_search_matches_moving_ai_arena2_bucket_80_optimal_length() {
384        let map = MovingAiMap::parse_str(include_str!("../benchdata/moving_ai/dao/arena2.map"))
385            .expect("arena2 MovingAI map should parse");
386        let scenario =
387            MovingAiScenario::parse_str(include_str!("../benchdata/moving_ai/dao/arena2.map.scen"))
388                .expect("arena2 MovingAI scenarios should parse")
389                .into_iter()
390                .find(|row| row.bucket == 80)
391                .expect("arena2 MovingAI bucket 80 scenario should exist");
392
393        let planner = FringeSearchPlanner::from_obstacle_points(
394            &map.to_obstacles(),
395            FringeSearchConfig {
396                resolution: 1.0,
397                robot_radius: 0.5,
398                heuristic_weight: 1.0,
399            },
400        )
401        .expect("Fringe Search planner should build from arena2 obstacles");
402
403        let start = map
404            .planning_point(scenario.start_x, scenario.start_y)
405            .expect("arena2 start should be valid");
406        let goal = map
407            .planning_point(scenario.goal_x, scenario.goal_y)
408            .expect("arena2 goal should be valid");
409
410        let path = planner
411            .plan(start, goal)
412            .expect("Fringe Search should solve the arena2 bucket 80 scenario");
413
414        assert!(
415            (path.total_length() - scenario.optimal_length).abs() < 1e-6,
416            "Fringe Search path length {} should match MovingAI optimal {} when corner cutting is disabled",
417            path.total_length(),
418            scenario.optimal_length
419        );
420    }
421}