1use 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#[derive(Debug, Clone)]
14pub struct FringeSearchConfig {
15 pub resolution: f64,
17 pub robot_radius: f64,
19 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#[derive(Debug)]
60pub struct FringeSearchPlanner {
61 grid_map: GridMap,
62 config: FringeSearchConfig,
63 motion: Vec<(i32, i32, f64)>,
64}
65
66#[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 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 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 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 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 pub fn plan(&self, start: Point2D, goal: Point2D) -> RoboticsResult<Path2D> {
124 self.plan_impl(start, goal).map(|(path, _stats)| path)
125 }
126
127 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 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(¤t_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}