1#![allow(clippy::collapsible_if)]
2
3use std::cmp::Ordering;
13use std::collections::{BinaryHeap, HashMap};
14
15use crate::grid::{GridMap, Node};
16use rust_robotics_core::{Obstacles, Path2D, PathPlanner, Point2D, RoboticsError, RoboticsResult};
17
18#[derive(Debug, Clone)]
20pub struct JPSConfig {
21 pub resolution: f64,
23 pub robot_radius: f64,
25 pub heuristic_weight: f64,
27}
28
29impl Default for JPSConfig {
30 fn default() -> Self {
31 Self {
32 resolution: 1.0,
33 robot_radius: 0.5,
34 heuristic_weight: 1.0,
35 }
36 }
37}
38
39impl JPSConfig {
40 pub fn validate(&self) -> RoboticsResult<()> {
41 if !self.resolution.is_finite() || self.resolution <= 0.0 {
42 return Err(RoboticsError::InvalidParameter(format!(
43 "resolution must be positive and finite, got {}",
44 self.resolution
45 )));
46 }
47 if !self.robot_radius.is_finite() || self.robot_radius < 0.0 {
48 return Err(RoboticsError::InvalidParameter(format!(
49 "robot_radius must be non-negative and finite, got {}",
50 self.robot_radius
51 )));
52 }
53 if !self.heuristic_weight.is_finite() || self.heuristic_weight <= 0.0 {
54 return Err(RoboticsError::InvalidParameter(format!(
55 "heuristic_weight must be positive and finite, got {}",
56 self.heuristic_weight
57 )));
58 }
59
60 Ok(())
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum JPSFallbackReason {
67 InvalidJumpPath,
69 SuboptimalJumpPath,
71 SearchExhausted,
73}
74
75impl JPSFallbackReason {
76 pub fn as_str(self) -> &'static str {
77 match self {
78 Self::InvalidJumpPath => "invalid_jump_path",
79 Self::SuboptimalJumpPath => "suboptimal_jump_path",
80 Self::SearchExhausted => "search_exhausted",
81 }
82 }
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87pub enum JPSInvalidJumpPathDetail {
88 InvalidStepSequence,
90 CostMismatch,
93 InvalidStepSequenceAndCostMismatch,
95}
96
97impl JPSInvalidJumpPathDetail {
98 pub fn as_str(self) -> &'static str {
99 match self {
100 Self::InvalidStepSequence => "invalid_step_sequence",
101 Self::CostMismatch => "cost_mismatch",
102 Self::InvalidStepSequenceAndCostMismatch => "invalid_step_and_cost_mismatch",
103 }
104 }
105}
106
107#[derive(Debug, Clone, PartialEq)]
109pub struct JPSDetourSegment {
110 pub from_x: i32,
111 pub from_y: i32,
112 pub to_x: i32,
113 pub to_y: i32,
114 pub expected_length: f64,
115 pub reconstructed_length: f64,
116}
117
118#[derive(Debug, Clone, PartialEq)]
120pub struct JPSCostMismatchMetrics {
121 pub search_cost: f64,
122 pub reconstructed_path_length: f64,
123 pub delta: f64,
124 pub detour_segment_count: usize,
125 pub detour_segments: Vec<JPSDetourSegment>,
126}
127
128#[derive(Debug, Clone, PartialEq)]
130pub struct JPSPlanDiagnostics {
131 pub used_fallback: bool,
132 pub fallback_reason: Option<JPSFallbackReason>,
133 pub invalid_jump_path_detail: Option<JPSInvalidJumpPathDetail>,
134 pub cost_mismatch_metrics: Option<JPSCostMismatchMetrics>,
135}
136
137#[derive(Debug, Clone)]
139pub struct JPSPlanResult {
140 pub path: Path2D,
141 pub diagnostics: JPSPlanDiagnostics,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
146struct Direction {
147 dx: i32,
148 dy: i32,
149}
150
151impl Direction {
152 fn new(dx: i32, dy: i32) -> Self {
153 Self { dx, dy }
154 }
155
156 fn is_diagonal(&self) -> bool {
157 self.dx != 0 && self.dy != 0
158 }
159
160 fn is_cardinal(&self) -> bool {
161 !self.is_diagonal()
162 }
163}
164
165#[derive(Debug)]
167struct PriorityNode {
168 x: i32,
169 y: i32,
170 cost: f64,
171 priority: f64,
172 index: usize,
173}
174
175impl Eq for PriorityNode {}
176
177impl PartialEq for PriorityNode {
178 fn eq(&self, other: &Self) -> bool {
179 self.priority == other.priority
180 }
181}
182
183impl Ord for PriorityNode {
184 fn cmp(&self, other: &Self) -> Ordering {
185 other
187 .priority
188 .partial_cmp(&self.priority)
189 .unwrap_or(Ordering::Equal)
190 }
191}
192
193impl PartialOrd for PriorityNode {
194 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
195 Some(self.cmp(other))
196 }
197}
198
199pub struct JPSPlanner {
201 grid_map: GridMap,
202 config: JPSConfig,
203}
204
205impl JPSPlanner {
206 fn state_key(&self, x: i32, y: i32, dir: Option<Direction>) -> (i32, Option<Direction>) {
207 (self.grid_map.calc_index(x, y), dir)
208 }
209
210 pub fn new(ox: &[f64], oy: &[f64], config: JPSConfig) -> Self {
212 Self::try_new(ox, oy, config).expect(
213 "invalid JPS planner input: obstacle list must be non-empty and valid, and config values must be positive/finite",
214 )
215 }
216
217 pub fn try_new(ox: &[f64], oy: &[f64], config: JPSConfig) -> RoboticsResult<Self> {
219 config.validate()?;
220 let grid_map = GridMap::try_new(ox, oy, config.resolution, config.robot_radius)?;
221 Ok(JPSPlanner { grid_map, config })
222 }
223
224 pub fn from_obstacles(ox: &[f64], oy: &[f64], resolution: f64, robot_radius: f64) -> Self {
226 let config = JPSConfig {
227 resolution,
228 robot_radius,
229 ..Default::default()
230 };
231 Self::new(ox, oy, config)
232 }
233
234 pub fn from_obstacle_points(obstacles: &Obstacles, config: JPSConfig) -> RoboticsResult<Self> {
236 config.validate()?;
237 let grid_map = GridMap::from_obstacles(obstacles, config.resolution, config.robot_radius)?;
238 Ok(JPSPlanner { grid_map, config })
239 }
240
241 #[deprecated(note = "use plan() or plan_xy() instead")]
243 pub fn planning(&self, sx: f64, sy: f64, gx: f64, gy: f64) -> Option<(Vec<f64>, Vec<f64>)> {
244 match self.plan_xy(sx, sy, gx, gy) {
245 Ok(path) => Some((path.x_coords(), path.y_coords())),
246 Err(_) => None,
247 }
248 }
249
250 pub fn plan(&self, start: Point2D, goal: Point2D) -> RoboticsResult<Path2D> {
252 self.plan_with_diagnostics(start, goal)
253 .map(|result| result.path)
254 }
255
256 pub fn plan_xy(&self, sx: f64, sy: f64, gx: f64, gy: f64) -> RoboticsResult<Path2D> {
258 self.plan_with_diagnostics(Point2D::new(sx, sy), Point2D::new(gx, gy))
259 .map(|result| result.path)
260 }
261
262 pub fn plan_with_diagnostics(
264 &self,
265 start: Point2D,
266 goal: Point2D,
267 ) -> RoboticsResult<JPSPlanResult> {
268 self.plan_impl(start, goal)
269 }
270
271 pub fn grid_map(&self) -> &GridMap {
273 &self.grid_map
274 }
275
276 fn calc_heuristic(&self, n1_x: i32, n1_y: i32, n2_x: i32, n2_y: i32) -> f64 {
277 let dx = (n1_x - n2_x).abs() as f64;
279 let dy = (n1_y - n2_y).abs() as f64;
280 let d_min = dx.min(dy);
281 let d_max = dx.max(dy);
282 self.config.heuristic_weight * (d_min * std::f64::consts::SQRT_2 + (d_max - d_min))
283 }
284
285 fn has_forced_neighbor(&self, x: i32, y: i32, dir: Direction) -> bool {
287 if dir.is_cardinal() {
288 if dir.dx != 0 {
290 let blocked_up = !self.grid_map.is_valid(x - dir.dx, y + 1);
292 let open_up = self.grid_map.is_valid(x, y + 1);
293 let blocked_down = !self.grid_map.is_valid(x - dir.dx, y - 1);
294 let open_down = self.grid_map.is_valid(x, y - 1);
295 (blocked_up && open_up) || (blocked_down && open_down)
296 } else {
297 let blocked_right = !self.grid_map.is_valid(x + 1, y - dir.dy);
299 let open_right = self.grid_map.is_valid(x + 1, y);
300 let blocked_left = !self.grid_map.is_valid(x - 1, y - dir.dy);
301 let open_left = self.grid_map.is_valid(x - 1, y);
302 (blocked_right && open_right) || (blocked_left && open_left)
303 }
304 } else {
305 false
306 }
307 }
308
309 fn jump(&self, x: i32, y: i32, dir: Direction, goal_x: i32, goal_y: i32) -> Option<(i32, i32)> {
311 let nx = x + dir.dx;
312 let ny = y + dir.dy;
313
314 if !self.grid_map.is_valid_step(x, y, nx, ny) {
315 return None;
316 }
317
318 if nx == goal_x && ny == goal_y {
319 return Some((nx, ny));
320 }
321
322 if self.has_forced_neighbor(nx, ny, dir) {
323 return Some((nx, ny));
324 }
325
326 if dir.is_diagonal() {
327 if self
328 .jump(nx, ny, Direction::new(dir.dx, 0), goal_x, goal_y)
329 .is_some()
330 {
331 return Some((nx, ny));
332 }
333 if self
334 .jump(nx, ny, Direction::new(0, dir.dy), goal_x, goal_y)
335 .is_some()
336 {
337 return Some((nx, ny));
338 }
339 }
340
341 self.jump(nx, ny, dir, goal_x, goal_y)
342 }
343
344 fn get_successors(
345 &self,
346 x: i32,
347 y: i32,
348 parent_dir: Option<Direction>,
349 goal_x: i32,
350 goal_y: i32,
351 ) -> Vec<(i32, i32, Direction)> {
352 let mut successors = Vec::new();
353 let directions = self.get_neighbors(x, y, parent_dir);
354
355 for dir in directions {
356 if let Some((jx, jy)) = self.jump(x, y, dir, goal_x, goal_y) {
357 successors.push((jx, jy, dir));
358 }
359 }
360
361 successors
362 }
363
364 fn get_neighbors(&self, x: i32, y: i32, parent_dir: Option<Direction>) -> Vec<Direction> {
365 match parent_dir {
366 None => {
367 let mut dirs = Vec::new();
368 for dx in -1..=1 {
369 for dy in -1..=1 {
370 if dx == 0 && dy == 0 {
371 continue;
372 }
373 let dir = Direction::new(dx, dy);
374 if self.grid_map.is_valid_step(x, y, x + dx, y + dy) {
375 dirs.push(dir);
376 }
377 }
378 }
379 dirs
380 }
381 Some(dir) => {
382 let mut dirs = Vec::new();
383
384 if dir.is_diagonal() {
385 if self.grid_map.is_valid_step(x, y, x + dir.dx, y + dir.dy) {
386 dirs.push(dir);
387 }
388 if self.grid_map.is_valid_step(x, y, x + dir.dx, y) {
389 dirs.push(Direction::new(dir.dx, 0));
390 }
391 if self.grid_map.is_valid_step(x, y, x, y + dir.dy) {
392 dirs.push(Direction::new(0, dir.dy));
393 }
394
395 if !self.grid_map.is_valid(x - dir.dx, y) {
396 if self.grid_map.is_valid_step(x, y, x - dir.dx, y + dir.dy) {
397 dirs.push(Direction::new(-dir.dx, dir.dy));
398 }
399 }
400 if !self.grid_map.is_valid(x, y - dir.dy) {
401 if self.grid_map.is_valid_step(x, y, x + dir.dx, y - dir.dy) {
402 dirs.push(Direction::new(dir.dx, -dir.dy));
403 }
404 }
405 } else if dir.dx != 0 {
406 if self.grid_map.is_valid_step(x, y, x + dir.dx, y) {
407 dirs.push(dir);
408 }
409 let blocked_lower = !self.grid_map.is_valid(x - dir.dx, y + 1);
410 if blocked_lower {
411 if self.grid_map.is_valid_step(x, y, x + dir.dx, y + 1) {
412 dirs.push(Direction::new(dir.dx, 1));
413 }
414 if self.grid_map.is_valid_step(x, y, x, y + 1) {
415 dirs.push(Direction::new(0, 1));
416 }
417 }
418 let blocked_upper = !self.grid_map.is_valid(x - dir.dx, y - 1);
419 if blocked_upper {
420 if self.grid_map.is_valid_step(x, y, x + dir.dx, y - 1) {
421 dirs.push(Direction::new(dir.dx, -1));
422 }
423 if self.grid_map.is_valid_step(x, y, x, y - 1) {
424 dirs.push(Direction::new(0, -1));
425 }
426 }
427 } else {
428 if self.grid_map.is_valid_step(x, y, x, y + dir.dy) {
429 dirs.push(dir);
430 }
431 let blocked_right = !self.grid_map.is_valid(x + 1, y - dir.dy);
432 if blocked_right {
433 if self.grid_map.is_valid_step(x, y, x + 1, y + dir.dy) {
434 dirs.push(Direction::new(1, dir.dy));
435 }
436 if self.grid_map.is_valid_step(x, y, x + 1, y) {
437 dirs.push(Direction::new(1, 0));
438 }
439 }
440 let blocked_left = !self.grid_map.is_valid(x - 1, y - dir.dy);
441 if blocked_left {
442 if self.grid_map.is_valid_step(x, y, x - 1, y + dir.dy) {
443 dirs.push(Direction::new(-1, dir.dy));
444 }
445 if self.grid_map.is_valid_step(x, y, x - 1, y) {
446 dirs.push(Direction::new(-1, 0));
447 }
448 }
449 }
450
451 dirs
452 }
453 }
454 }
455
456 fn calc_distance(&self, x1: i32, y1: i32, x2: i32, y2: i32) -> f64 {
457 let dx = (x2 - x1).abs() as f64;
458 let dy = (y2 - y1).abs() as f64;
459 let d_min = dx.min(dy);
460 let d_max = dx.max(dy);
461 d_min * std::f64::consts::SQRT_2 + (d_max - d_min)
462 }
463
464 fn grid_point(&self, x: i32, y: i32) -> Point2D {
465 Point2D::new(
466 self.grid_map.calc_x_position(x),
467 self.grid_map.calc_y_position(y),
468 )
469 }
470
471 fn collect_jump_points(&self, goal_index: usize, node_storage: &[Node]) -> Vec<(i32, i32)> {
472 let mut jump_points = Vec::new();
473 let mut current_index = Some(goal_index);
474
475 while let Some(index) = current_index {
476 let node = &node_storage[index];
477 jump_points.push((node.x, node.y));
478 current_index = node.parent_index;
479 }
480
481 jump_points.reverse();
482 jump_points
483 }
484
485 fn append_reconstructed_segment(
486 &self,
487 points: &mut Vec<Point2D>,
488 from: (i32, i32),
489 to: (i32, i32),
490 ) -> f64 {
491 let (px, py) = from;
492 let (x, y) = to;
493 let dx = (x - px).signum();
494 let dy = (y - py).signum();
495 let mut cx = px;
496 let mut cy = py;
497 let mut reconstructed_length = 0.0;
498
499 while cx != x || cy != y {
500 let previous_x = cx;
501 let previous_y = cy;
502
503 if cx != x && cy != y {
504 if self.grid_map.is_valid_step(cx, cy, cx + dx, cy + dy) {
505 cx += dx;
506 cy += dy;
507 } else if self.grid_map.is_valid_step(cx, cy, cx + dx, cy) {
508 cx += dx;
509 } else if self.grid_map.is_valid_step(cx, cy, cx, cy + dy) {
510 cy += dy;
511 } else {
512 cx += dx;
513 cy += dy;
514 }
515 } else if cx != x {
516 cx += dx;
517 } else {
518 cy += dy;
519 }
520
521 reconstructed_length += self.calc_distance(previous_x, previous_y, cx, cy);
522 points.push(self.grid_point(cx, cy));
523 }
524
525 reconstructed_length
526 }
527
528 fn build_path_with_metrics(
529 &self,
530 goal_index: usize,
531 node_storage: &[Node],
532 ) -> (Path2D, Vec<JPSDetourSegment>) {
533 let jump_points = self.collect_jump_points(goal_index, node_storage);
534 let mut points = Vec::new();
535 let mut detour_segments = Vec::new();
536
537 if let Some(&(start_x, start_y)) = jump_points.first() {
538 points.push(self.grid_point(start_x, start_y));
539 }
540
541 for segment in jump_points.windows(2) {
542 let from = segment[0];
543 let to = segment[1];
544 let reconstructed_length = self.append_reconstructed_segment(&mut points, from, to);
545 let expected_length = self.calc_distance(from.0, from.1, to.0, to.1);
546 if (reconstructed_length - expected_length).abs() > 1e-6 {
547 detour_segments.push(JPSDetourSegment {
548 from_x: from.0,
549 from_y: from.1,
550 to_x: to.0,
551 to_y: to.1,
552 expected_length,
553 reconstructed_length,
554 });
555 }
556 }
557
558 (Path2D::from_points(points), detour_segments)
559 }
560
561 fn build_search_path(&self, goal_index: usize, node_storage: &[Node]) -> Path2D {
562 let points: Vec<Point2D> = self
563 .collect_jump_points(goal_index, node_storage)
564 .into_iter()
565 .map(|(x, y)| self.grid_point(x, y))
566 .collect();
567 Path2D::from_points(points)
568 }
569
570 fn cost_mismatch_metrics(
571 &self,
572 detail: Option<JPSInvalidJumpPathDetail>,
573 path: &Path2D,
574 search_cost: f64,
575 detour_segments: Vec<JPSDetourSegment>,
576 ) -> Option<JPSCostMismatchMetrics> {
577 match detail {
578 Some(JPSInvalidJumpPathDetail::CostMismatch)
579 | Some(JPSInvalidJumpPathDetail::InvalidStepSequenceAndCostMismatch) => {
580 let reconstructed_path_length = path.total_length();
581 Some(JPSCostMismatchMetrics {
582 search_cost,
583 reconstructed_path_length,
584 delta: reconstructed_path_length - search_cost,
585 detour_segment_count: detour_segments.len(),
586 detour_segments,
587 })
588 }
589 _ => None,
590 }
591 }
592
593 fn path_has_only_valid_steps(&self, path: &Path2D) -> bool {
594 path.points.windows(2).all(|segment| {
595 let from_x = self.grid_map.calc_x_index(segment[0].x);
596 let from_y = self.grid_map.calc_y_index(segment[0].y);
597 let to_x = self.grid_map.calc_x_index(segment[1].x);
598 let to_y = self.grid_map.calc_y_index(segment[1].y);
599 self.grid_map.is_valid_step(from_x, from_y, to_x, to_y)
600 })
601 }
602
603 fn classify_invalid_jump_path(
604 &self,
605 path: &Path2D,
606 expected_cost: f64,
607 ) -> Option<JPSInvalidJumpPathDetail> {
608 let has_only_valid_steps = self.path_has_only_valid_steps(path);
609 let has_cost_mismatch = (path.total_length() - expected_cost).abs() > 1e-6;
610
611 match (has_only_valid_steps, has_cost_mismatch) {
612 (true, false) => None,
613 (false, false) => Some(JPSInvalidJumpPathDetail::InvalidStepSequence),
614 (true, true) => Some(JPSInvalidJumpPathDetail::CostMismatch),
615 (false, true) => Some(JPSInvalidJumpPathDetail::InvalidStepSequenceAndCostMismatch),
616 }
617 }
618
619 fn plan_with_grid_a_star(
620 &self,
621 start_x: i32,
622 start_y: i32,
623 goal_x: i32,
624 goal_y: i32,
625 ) -> RoboticsResult<Path2D> {
626 let mut open_set = BinaryHeap::new();
627 let mut closed_set: HashMap<i32, usize> = HashMap::new();
628 let mut node_storage: Vec<Node> = Vec::new();
629
630 node_storage.push(Node::new(start_x, start_y, 0.0, None));
631 open_set.push(PriorityNode {
632 x: start_x,
633 y: start_y,
634 cost: 0.0,
635 priority: self.calc_heuristic(start_x, start_y, goal_x, goal_y),
636 index: 0,
637 });
638
639 while let Some(current) = open_set.pop() {
640 let current_grid_index = self.grid_map.calc_index(current.x, current.y);
641
642 if current.x == goal_x && current.y == goal_y {
643 return Ok(self.build_search_path(current.index, &node_storage));
644 }
645
646 if let Some(&existing_index) = closed_set.get(¤t_grid_index) {
647 if node_storage[existing_index].cost <= current.cost {
648 continue;
649 }
650 }
651
652 closed_set.insert(current_grid_index, current.index);
653
654 for dx in -1..=1 {
655 for dy in -1..=1 {
656 if dx == 0 && dy == 0 {
657 continue;
658 }
659
660 if !self.grid_map.is_valid_offset(current.x, current.y, dx, dy) {
661 continue;
662 }
663
664 let new_x = current.x + dx;
665 let new_y = current.y + dy;
666 let move_cost = if dx != 0 && dy != 0 {
667 std::f64::consts::SQRT_2
668 } else {
669 1.0
670 };
671 let new_cost = current.cost + move_cost;
672 let new_grid_index = self.grid_map.calc_index(new_x, new_y);
673
674 if let Some(&existing_index) = closed_set.get(&new_grid_index) {
675 if node_storage[existing_index].cost <= new_cost {
676 continue;
677 }
678 }
679
680 node_storage.push(Node::new(new_x, new_y, new_cost, Some(current.index)));
681 let new_index = node_storage.len() - 1;
682 open_set.push(PriorityNode {
683 x: new_x,
684 y: new_y,
685 cost: new_cost,
686 priority: new_cost + self.calc_heuristic(new_x, new_y, goal_x, goal_y),
687 index: new_index,
688 });
689 }
690 }
691 }
692
693 Err(RoboticsError::PlanningError("No path found".to_string()))
694 }
695
696 fn direct_result(&self, path: Path2D) -> JPSPlanResult {
697 JPSPlanResult {
698 path,
699 diagnostics: JPSPlanDiagnostics {
700 used_fallback: false,
701 fallback_reason: None,
702 invalid_jump_path_detail: None,
703 cost_mismatch_metrics: None,
704 },
705 }
706 }
707
708 fn fallback_result_with_path(
709 &self,
710 path: Path2D,
711 fallback_reason: JPSFallbackReason,
712 invalid_jump_path_detail: Option<JPSInvalidJumpPathDetail>,
713 cost_mismatch_metrics: Option<JPSCostMismatchMetrics>,
714 ) -> JPSPlanResult {
715 JPSPlanResult {
716 path,
717 diagnostics: JPSPlanDiagnostics {
718 used_fallback: true,
719 fallback_reason: Some(fallback_reason),
720 invalid_jump_path_detail,
721 cost_mismatch_metrics,
722 },
723 }
724 }
725
726 #[allow(clippy::too_many_arguments)]
727 fn fallback_result(
728 &self,
729 start_x: i32,
730 start_y: i32,
731 goal_x: i32,
732 goal_y: i32,
733 fallback_reason: JPSFallbackReason,
734 invalid_jump_path_detail: Option<JPSInvalidJumpPathDetail>,
735 cost_mismatch_metrics: Option<JPSCostMismatchMetrics>,
736 ) -> RoboticsResult<JPSPlanResult> {
737 self.plan_with_grid_a_star(start_x, start_y, goal_x, goal_y)
738 .map(|path| {
739 self.fallback_result_with_path(
740 path,
741 fallback_reason,
742 invalid_jump_path_detail,
743 cost_mismatch_metrics,
744 )
745 })
746 }
747
748 fn ensure_query_is_valid(&self, x: i32, y: i32, label: &str) -> RoboticsResult<()> {
749 if self.grid_map.is_valid(x, y) {
750 return Ok(());
751 }
752
753 Err(RoboticsError::PlanningError(format!(
754 "{} position is invalid",
755 label
756 )))
757 }
758
759 fn plan_impl(&self, start: Point2D, goal: Point2D) -> RoboticsResult<JPSPlanResult> {
760 let start_x = self.grid_map.calc_x_index(start.x);
761 let start_y = self.grid_map.calc_y_index(start.y);
762 let goal_x = self.grid_map.calc_x_index(goal.x);
763 let goal_y = self.grid_map.calc_y_index(goal.y);
764
765 self.ensure_query_is_valid(start_x, start_y, "Start")?;
766 self.ensure_query_is_valid(goal_x, goal_y, "Goal")?;
767
768 let mut open_set = BinaryHeap::new();
769 let mut closed_set: HashMap<(i32, Option<Direction>), usize> = HashMap::new();
770 let mut node_storage: Vec<Node> = Vec::new();
771 let mut direction_map: HashMap<usize, Option<Direction>> = HashMap::new();
772
773 node_storage.push(Node::new(start_x, start_y, 0.0, None));
774 let start_index = 0;
775 direction_map.insert(start_index, None);
776
777 open_set.push(PriorityNode {
778 x: start_x,
779 y: start_y,
780 cost: 0.0,
781 priority: self.calc_heuristic(start_x, start_y, goal_x, goal_y),
782 index: start_index,
783 });
784
785 while let Some(current) = open_set.pop() {
786 let current_dir = direction_map.get(¤t.index).copied().flatten();
787 let current_state_key = self.state_key(current.x, current.y, current_dir);
788
789 if current.x == goal_x && current.y == goal_y {
790 let (path, detour_segments) =
791 self.build_path_with_metrics(current.index, &node_storage);
792 let invalid_jump_path_detail = self.classify_invalid_jump_path(&path, current.cost);
793 if invalid_jump_path_detail.is_none() {
794 let reference_path =
795 self.plan_with_grid_a_star(start_x, start_y, goal_x, goal_y)?;
796 if reference_path.total_length() + 1e-6 < path.total_length() {
797 return Ok(self.fallback_result_with_path(
798 reference_path,
799 JPSFallbackReason::SuboptimalJumpPath,
800 None,
801 None,
802 ));
803 }
804 return Ok(self.direct_result(path));
805 }
806 let cost_mismatch_metrics = self.cost_mismatch_metrics(
807 invalid_jump_path_detail,
808 &path,
809 current.cost,
810 detour_segments,
811 );
812
813 return self.fallback_result(
814 start_x,
815 start_y,
816 goal_x,
817 goal_y,
818 JPSFallbackReason::InvalidJumpPath,
819 invalid_jump_path_detail,
820 cost_mismatch_metrics,
821 );
822 }
823
824 if let Some(&existing_index) = closed_set.get(¤t_state_key) {
825 if node_storage[existing_index].cost <= current.cost {
826 continue;
827 }
828 }
829
830 closed_set.insert(current_state_key, current.index);
831
832 let successors = self.get_successors(current.x, current.y, current_dir, goal_x, goal_y);
833
834 for (jx, jy, dir) in successors {
835 let new_cost = current.cost + self.calc_distance(current.x, current.y, jx, jy);
836 let new_state_key = self.state_key(jx, jy, Some(dir));
837
838 if let Some(&existing_index) = closed_set.get(&new_state_key) {
839 if node_storage[existing_index].cost <= new_cost {
840 continue;
841 }
842 }
843
844 node_storage.push(Node::new(jx, jy, new_cost, Some(current.index)));
845 let new_index = node_storage.len() - 1;
846 direction_map.insert(new_index, Some(dir));
847
848 let priority = new_cost + self.calc_heuristic(jx, jy, goal_x, goal_y);
849 open_set.push(PriorityNode {
850 x: jx,
851 y: jy,
852 cost: new_cost,
853 priority,
854 index: new_index,
855 });
856 }
857 }
858
859 self.fallback_result(
860 start_x,
861 start_y,
862 goal_x,
863 goal_y,
864 JPSFallbackReason::SearchExhausted,
865 None,
866 None,
867 )
868 }
869}
870
871impl PathPlanner for JPSPlanner {
872 fn plan(&self, start: Point2D, goal: Point2D) -> Result<Path2D, RoboticsError> {
873 self.plan_with_diagnostics(start, goal)
874 .map(|result| result.path)
875 }
876}
877
878#[cfg(test)]
879mod tests {
880 #![allow(dead_code)]
881
882 use super::*;
883
884 use crate::a_star::{AStarConfig, AStarPlanner};
885 use crate::moving_ai::{MovingAiMap, MovingAiScenario};
886 use rust_robotics_core::Obstacles;
887 use std::collections::{BinaryHeap, HashMap, HashSet};
888 use std::hint::black_box;
889 use std::time::Instant;
890
891 #[derive(Debug)]
892 struct RawTraceSuccessor {
893 x: i32,
894 y: i32,
895 dir: Direction,
896 }
897
898 #[derive(Debug)]
899 struct RawTraceExpansion {
900 node_index: usize,
901 parent_index: Option<usize>,
902 x: i32,
903 y: i32,
904 cost: f64,
905 parent_dir: Option<Direction>,
906 successors: Vec<RawTraceSuccessor>,
907 }
908
909 #[derive(Debug)]
910 enum RawTraceOutcome {
911 GoalReached {
912 jump_points: Vec<(i32, i32)>,
913 path_length: f64,
914 search_cost: f64,
915 invalid_jump_path_detail: Option<JPSInvalidJumpPathDetail>,
916 },
917 SearchExhausted,
918 }
919
920 #[derive(Debug)]
921 struct RawTrace {
922 expansions: Vec<RawTraceExpansion>,
923 outcome: RawTraceOutcome,
924 }
925
926 #[derive(Debug)]
927 struct RawJumpChainStats {
928 segment_count: usize,
929 short_segment_count: usize,
930 direction_change_count: usize,
931 }
932
933 #[derive(Debug)]
934 struct NodeProbeCandidate {
935 dir: Direction,
936 jump_target: Option<(i32, i32)>,
937 }
938
939 fn trace_raw_jps(
940 planner: &JPSPlanner,
941 start: Point2D,
942 goal: Point2D,
943 max_expansions: usize,
944 ) -> RawTrace {
945 let start_x = planner.grid_map.calc_x_index(start.x);
946 let start_y = planner.grid_map.calc_y_index(start.y);
947 let goal_x = planner.grid_map.calc_x_index(goal.x);
948 let goal_y = planner.grid_map.calc_y_index(goal.y);
949
950 planner
951 .ensure_query_is_valid(start_x, start_y, "Start")
952 .expect("trace start should be valid");
953 planner
954 .ensure_query_is_valid(goal_x, goal_y, "Goal")
955 .expect("trace goal should be valid");
956
957 let mut open_set = BinaryHeap::new();
958 let mut closed_set: HashMap<(i32, Option<Direction>), usize> = HashMap::new();
959 let mut node_storage: Vec<Node> = Vec::new();
960 let mut direction_map: HashMap<usize, Option<Direction>> = HashMap::new();
961 let mut expansions = Vec::new();
962
963 node_storage.push(Node::new(start_x, start_y, 0.0, None));
964 direction_map.insert(0, None);
965 open_set.push(PriorityNode {
966 x: start_x,
967 y: start_y,
968 cost: 0.0,
969 priority: planner.calc_heuristic(start_x, start_y, goal_x, goal_y),
970 index: 0,
971 });
972
973 while let Some(current) = open_set.pop() {
974 let current_dir = direction_map.get(¤t.index).copied().flatten();
975 let current_state_key = planner.state_key(current.x, current.y, current_dir);
976
977 if current.x == goal_x && current.y == goal_y {
978 let (path, _) = planner.build_path_with_metrics(current.index, &node_storage);
979 let invalid_jump_path_detail =
980 planner.classify_invalid_jump_path(&path, current.cost);
981 return RawTrace {
982 expansions,
983 outcome: RawTraceOutcome::GoalReached {
984 jump_points: planner.collect_jump_points(current.index, &node_storage),
985 path_length: path.total_length(),
986 search_cost: current.cost,
987 invalid_jump_path_detail,
988 },
989 };
990 }
991
992 if let Some(&existing_index) = closed_set.get(¤t_state_key) {
993 if node_storage[existing_index].cost <= current.cost {
994 continue;
995 }
996 }
997
998 closed_set.insert(current_state_key, current.index);
999
1000 let successors =
1001 planner.get_successors(current.x, current.y, current_dir, goal_x, goal_y);
1002 let trace_successors = successors
1003 .iter()
1004 .map(|(x, y, dir)| RawTraceSuccessor {
1005 x: *x,
1006 y: *y,
1007 dir: *dir,
1008 })
1009 .collect();
1010 expansions.push(RawTraceExpansion {
1011 node_index: current.index,
1012 parent_index: node_storage[current.index].parent_index,
1013 x: current.x,
1014 y: current.y,
1015 cost: current.cost,
1016 parent_dir: current_dir,
1017 successors: trace_successors,
1018 });
1019 if expansions.len() >= max_expansions {
1020 return RawTrace {
1021 expansions,
1022 outcome: RawTraceOutcome::SearchExhausted,
1023 };
1024 }
1025
1026 for (jx, jy, dir) in successors {
1027 let new_cost = current.cost + planner.calc_distance(current.x, current.y, jx, jy);
1028 let new_state_key = planner.state_key(jx, jy, Some(dir));
1029
1030 if let Some(&existing_index) = closed_set.get(&new_state_key) {
1031 if node_storage[existing_index].cost <= new_cost {
1032 continue;
1033 }
1034 }
1035
1036 node_storage.push(Node::new(jx, jy, new_cost, Some(current.index)));
1037 let new_index = node_storage.len() - 1;
1038 direction_map.insert(new_index, Some(dir));
1039 open_set.push(PriorityNode {
1040 x: jx,
1041 y: jy,
1042 cost: new_cost,
1043 priority: new_cost + planner.calc_heuristic(jx, jy, goal_x, goal_y),
1044 index: new_index,
1045 });
1046 }
1047 }
1048
1049 RawTrace {
1050 expansions,
1051 outcome: RawTraceOutcome::SearchExhausted,
1052 }
1053 }
1054
1055 fn trace_chain_to_root(
1056 expansions: &[RawTraceExpansion],
1057 terminal_node_index: usize,
1058 ) -> Vec<&RawTraceExpansion> {
1059 let expansion_index_by_node: HashMap<usize, usize> = expansions
1060 .iter()
1061 .enumerate()
1062 .map(|(ix, expansion)| (expansion.node_index, ix))
1063 .collect();
1064
1065 let mut chain = Vec::new();
1066 let mut current = Some(terminal_node_index);
1067 while let Some(node_index) = current {
1068 let Some(&expansion_ix) = expansion_index_by_node.get(&node_index) else {
1069 break;
1070 };
1071 let expansion = &expansions[expansion_ix];
1072 chain.push(expansion);
1073 current = expansion.parent_index;
1074 }
1075 chain.reverse();
1076 chain
1077 }
1078
1079 fn summarize_jump_chain(jump_points: &[(i32, i32)]) -> RawJumpChainStats {
1080 if jump_points.len() < 2 {
1081 return RawJumpChainStats {
1082 segment_count: 0,
1083 short_segment_count: 0,
1084 direction_change_count: 0,
1085 };
1086 }
1087
1088 let mut short_segment_count = 0;
1089 let mut directions = Vec::new();
1090
1091 for segment in jump_points.windows(2) {
1092 let dx = segment[1].0 - segment[0].0;
1093 let dy = segment[1].1 - segment[0].1;
1094 let length = ((dx * dx + dy * dy) as f64).sqrt();
1095 if length <= std::f64::consts::SQRT_2 + 1e-6 {
1096 short_segment_count += 1;
1097 }
1098 directions.push((dx.signum(), dy.signum()));
1099 }
1100
1101 let direction_change_count = directions
1102 .windows(2)
1103 .filter(|pair| pair[0] != pair[1])
1104 .count();
1105
1106 RawJumpChainStats {
1107 segment_count: jump_points.len() - 1,
1108 short_segment_count,
1109 direction_change_count,
1110 }
1111 }
1112
1113 fn probe_node(
1114 planner: &JPSPlanner,
1115 x: i32,
1116 y: i32,
1117 parent_dir: Option<Direction>,
1118 goal_x: i32,
1119 goal_y: i32,
1120 ) -> Vec<NodeProbeCandidate> {
1121 planner
1122 .get_neighbors(x, y, parent_dir)
1123 .into_iter()
1124 .map(|dir| NodeProbeCandidate {
1125 jump_target: planner.jump(x, y, dir, goal_x, goal_y),
1126 dir,
1127 })
1128 .collect()
1129 }
1130
1131 fn densest_direction_change_window(
1132 jump_points: &[(i32, i32)],
1133 window_segments: usize,
1134 ) -> Option<(usize, usize, usize)> {
1135 if jump_points.len() < window_segments + 1 || window_segments < 2 {
1136 return None;
1137 }
1138
1139 let directions: Vec<(i32, i32)> = jump_points
1140 .windows(2)
1141 .map(|segment| {
1142 (
1143 (segment[1].0 - segment[0].0).signum(),
1144 (segment[1].1 - segment[0].1).signum(),
1145 )
1146 })
1147 .collect();
1148
1149 let mut best: Option<(usize, usize, usize)> = None;
1150 for start in 0..=directions.len() - window_segments {
1151 let end = start + window_segments;
1152 let change_count = directions[start..end]
1153 .windows(2)
1154 .filter(|pair| pair[0] != pair[1])
1155 .count();
1156 if best
1157 .map(|(_, _, best_count)| change_count > best_count)
1158 .unwrap_or(true)
1159 {
1160 best = Some((start, end, change_count));
1161 }
1162 }
1163 best
1164 }
1165
1166 fn first_short_segment_run(
1167 jump_points: &[(i32, i32)],
1168 min_run_len: usize,
1169 ) -> Option<(usize, usize)> {
1170 if jump_points.len() < 2 {
1171 return None;
1172 }
1173
1174 let mut run_start = None;
1175 let mut run_len = 0usize;
1176 for (ix, segment) in jump_points.windows(2).enumerate() {
1177 let dx = segment[1].0 - segment[0].0;
1178 let dy = segment[1].1 - segment[0].1;
1179 let length = ((dx * dx + dy * dy) as f64).sqrt();
1180 if length <= std::f64::consts::SQRT_2 + 1e-6 {
1181 if run_start.is_none() {
1182 run_start = Some(ix);
1183 }
1184 run_len += 1;
1185 if run_len >= min_run_len {
1186 return run_start.map(|start| (start, run_len));
1187 }
1188 } else {
1189 run_start = None;
1190 run_len = 0;
1191 }
1192 }
1193 None
1194 }
1195
1196 fn grid_points_from_path(planner: &JPSPlanner, path: &Path2D) -> Vec<(i32, i32)> {
1197 path.points
1198 .iter()
1199 .map(|point| {
1200 (
1201 planner.grid_map.calc_x_index(point.x),
1202 planner.grid_map.calc_y_index(point.y),
1203 )
1204 })
1205 .collect()
1206 }
1207
1208 fn turn_points_from_grid_path(points: &[(i32, i32)]) -> Vec<(i32, i32)> {
1209 if points.len() < 2 {
1210 return points.to_vec();
1211 }
1212
1213 let mut turn_points = vec![points[0]];
1214 let mut last_dir = (
1215 (points[1].0 - points[0].0).signum(),
1216 (points[1].1 - points[0].1).signum(),
1217 );
1218
1219 for window in points.windows(3) {
1220 let current_dir = (
1221 (window[2].0 - window[1].0).signum(),
1222 (window[2].1 - window[1].1).signum(),
1223 );
1224 if current_dir != last_dir {
1225 turn_points.push(window[1]);
1226 last_dir = current_dir;
1227 }
1228 }
1229
1230 turn_points.push(*points.last().unwrap());
1231 turn_points
1232 }
1233
1234 fn first_missing_reference_turn_point(
1235 reference_turn_points: &[(i32, i32)],
1236 raw_trace: &RawTrace,
1237 ) -> Option<(usize, (i32, i32), bool, bool)> {
1238 let expanded_positions: HashSet<(i32, i32)> = raw_trace
1239 .expansions
1240 .iter()
1241 .map(|expansion| (expansion.x, expansion.y))
1242 .collect();
1243 let generated_positions: HashSet<(i32, i32)> = raw_trace
1244 .expansions
1245 .iter()
1246 .flat_map(|expansion| expansion.successors.iter().map(|succ| (succ.x, succ.y)))
1247 .collect();
1248
1249 reference_turn_points
1250 .iter()
1251 .enumerate()
1252 .skip(1)
1253 .find_map(|(ix, &point)| {
1254 let expanded = expanded_positions.contains(&point);
1255 let generated = generated_positions.contains(&point);
1256 if expanded || generated {
1257 None
1258 } else {
1259 Some((ix, point, expanded, generated))
1260 }
1261 })
1262 }
1263
1264 #[allow(clippy::type_complexity)]
1265 fn first_divergent_turn_point(
1266 reference_turn_points: &[(i32, i32)],
1267 raw_jump_points: &[(i32, i32)],
1268 ) -> Option<(usize, Option<(i32, i32)>, Option<(i32, i32)>)> {
1269 let max_len = reference_turn_points.len().max(raw_jump_points.len());
1270 for ix in 0..max_len {
1271 let reference_point = reference_turn_points.get(ix).copied();
1272 let raw_point = raw_jump_points.get(ix).copied();
1273 if reference_point != raw_point {
1274 return Some((ix, reference_point, raw_point));
1275 }
1276 }
1277 None
1278 }
1279
1280 fn create_simple_obstacles() -> (Vec<f64>, Vec<f64>) {
1281 let mut ox = Vec::new();
1282 let mut oy = Vec::new();
1283
1284 for i in 0..61 {
1285 ox.push(i as f64);
1286 oy.push(0.0);
1287 ox.push(i as f64);
1288 oy.push(60.0);
1289 ox.push(0.0);
1290 oy.push(i as f64);
1291 ox.push(60.0);
1292 oy.push(i as f64);
1293 }
1294
1295 for i in 20..40 {
1296 ox.push(30.0);
1297 oy.push(i as f64);
1298 }
1299
1300 (ox, oy)
1301 }
1302
1303 const MOVING_AI_CASES: [(&str, &str, &str); 3] = [
1304 (
1305 "arena2",
1306 include_str!("../benchdata/moving_ai/dao/arena2.map"),
1307 include_str!("../benchdata/moving_ai/dao/arena2.map.scen"),
1308 ),
1309 (
1310 "8room_000",
1311 include_str!("../benchdata/moving_ai/room/8room_000.map"),
1312 include_str!("../benchdata/moving_ai/room/8room_000.map.scen"),
1313 ),
1314 (
1315 "random512-10-0",
1316 include_str!("../benchdata/moving_ai/random/random512-10-0.map"),
1317 include_str!("../benchdata/moving_ai/random/random512-10-0.map.scen"),
1318 ),
1319 ];
1320
1321 fn assert_pure_jps_matches_moving_ai_bucket(
1322 name: &str,
1323 map_str: &str,
1324 scen_str: &str,
1325 bucket: u32,
1326 ) {
1327 let map = MovingAiMap::parse_str(map_str)
1328 .unwrap_or_else(|_| panic!("{name} MovingAI map should parse"));
1329 let scenario = MovingAiScenario::parse_str(scen_str)
1330 .unwrap_or_else(|_| panic!("{name} MovingAI scenarios should parse"))
1331 .into_iter()
1332 .find(|row| row.bucket == bucket)
1333 .unwrap_or_else(|| panic!("{name} MovingAI bucket {bucket} scenario should exist"));
1334
1335 let planner = JPSPlanner::from_obstacle_points(
1336 &map.to_obstacles(),
1337 JPSConfig {
1338 resolution: 1.0,
1339 robot_radius: 0.5,
1340 heuristic_weight: 1.0,
1341 },
1342 )
1343 .unwrap_or_else(|_| panic!("JPS planner should build from {name} obstacles"));
1344
1345 let start = map
1346 .planning_point(scenario.start_x, scenario.start_y)
1347 .unwrap_or_else(|_| panic!("{name} start should be valid"));
1348 let goal = map
1349 .planning_point(scenario.goal_x, scenario.goal_y)
1350 .unwrap_or_else(|_| panic!("{name} goal should be valid"));
1351
1352 let result = planner
1353 .plan_with_diagnostics(start, goal)
1354 .unwrap_or_else(|_| panic!("JPS should solve the {name} bucket {bucket} scenario"));
1355
1356 assert!(
1357 !result.path.is_empty(),
1358 "{name} bucket {bucket} path should not be empty"
1359 );
1360 println!(
1361 "{name}/bucket{bucket}: used_fallback={}, reason={:?}, detail={:?}, path_length={:.6}",
1362 result.diagnostics.used_fallback,
1363 result.diagnostics.fallback_reason,
1364 result.diagnostics.invalid_jump_path_detail,
1365 result.path.total_length()
1366 );
1367 assert!(
1368 !result.diagnostics.used_fallback,
1369 "{name} bucket {bucket} should solve as pure JPS, got reason={:?}, detail={:?}, path_length={:.6}",
1370 result.diagnostics.fallback_reason,
1371 result.diagnostics.invalid_jump_path_detail,
1372 result.path.total_length()
1373 );
1374 assert_eq!(
1375 result.diagnostics.fallback_reason, None,
1376 "{name} bucket {bucket} pure-JPS regression should not report a fallback reason"
1377 );
1378 assert_eq!(
1379 result.diagnostics.invalid_jump_path_detail,
1380 None,
1381 "{name} bucket {bucket} pure-JPS regression should not report an invalid_jump_path detail"
1382 );
1383 assert!(
1384 (result.path.total_length() - scenario.optimal_length).abs() <= 1e-6,
1385 "{name} bucket {bucket} pure JPS path length {} should match MovingAI optimal {}",
1386 result.path.total_length(),
1387 scenario.optimal_length
1388 );
1389
1390 let grid = planner.grid_map();
1391 for segment in result.path.points.windows(2) {
1392 let from_x = grid.calc_x_index(segment[0].x);
1393 let from_y = grid.calc_y_index(segment[0].y);
1394 let to_x = grid.calc_x_index(segment[1].x);
1395 let to_y = grid.calc_y_index(segment[1].y);
1396 assert!(
1397 grid.is_valid_step(from_x, from_y, to_x, to_y),
1398 "{name} bucket {bucket} path contains an invalid step from ({from_x}, {from_y}) to ({to_x}, {to_y})"
1399 );
1400 }
1401 }
1402
1403 fn build_moving_ai_bucket_planners(
1404 name: &str,
1405 map_str: &str,
1406 scen_str: &str,
1407 bucket: u32,
1408 ) -> (AStarPlanner, JPSPlanner, Point2D, Point2D, f64) {
1409 let map = MovingAiMap::parse_str(map_str)
1410 .unwrap_or_else(|_| panic!("{name} MovingAI map should parse"));
1411 let scenario = MovingAiScenario::parse_str(scen_str)
1412 .unwrap_or_else(|_| panic!("{name} MovingAI scenarios should parse"))
1413 .into_iter()
1414 .find(|row| row.bucket == bucket)
1415 .unwrap_or_else(|| panic!("{name} MovingAI bucket {bucket} scenario should exist"));
1416 let obstacles = map.to_obstacles();
1417 let a_star = AStarPlanner::from_obstacle_points(
1418 &obstacles,
1419 AStarConfig {
1420 resolution: 1.0,
1421 robot_radius: 0.5,
1422 heuristic_weight: 1.0,
1423 },
1424 )
1425 .unwrap_or_else(|_| panic!("A* planner should build from {name} obstacles"));
1426 let jps = JPSPlanner::from_obstacle_points(
1427 &obstacles,
1428 JPSConfig {
1429 resolution: 1.0,
1430 robot_radius: 0.5,
1431 heuristic_weight: 1.0,
1432 },
1433 )
1434 .unwrap_or_else(|_| panic!("JPS planner should build from {name} obstacles"));
1435 let start = map
1436 .planning_point(scenario.start_x, scenario.start_y)
1437 .unwrap_or_else(|_| panic!("{name} start should be valid"));
1438 let goal = map
1439 .planning_point(scenario.goal_x, scenario.goal_y)
1440 .unwrap_or_else(|_| panic!("{name} goal should be valid"));
1441
1442 (a_star, jps, start, goal, scenario.optimal_length)
1443 }
1444
1445 fn build_moving_ai_family_planners(
1446 name: &str,
1447 map_str: &str,
1448 scen_str: &str,
1449 ) -> (MovingAiMap, Vec<MovingAiScenario>, AStarPlanner, JPSPlanner) {
1450 let map = MovingAiMap::parse_str(map_str)
1451 .unwrap_or_else(|_| panic!("{name} MovingAI map should parse"));
1452 let scenarios = MovingAiScenario::parse_str(scen_str)
1453 .unwrap_or_else(|_| panic!("{name} MovingAI scenarios should parse"));
1454 let obstacles = map.to_obstacles();
1455 let a_star = AStarPlanner::from_obstacle_points(
1456 &obstacles,
1457 AStarConfig {
1458 resolution: 1.0,
1459 robot_radius: 0.5,
1460 heuristic_weight: 1.0,
1461 },
1462 )
1463 .unwrap_or_else(|_| panic!("A* planner should build from {name} obstacles"));
1464 let jps = JPSPlanner::from_obstacle_points(
1465 &obstacles,
1466 JPSConfig {
1467 resolution: 1.0,
1468 robot_radius: 0.5,
1469 heuristic_weight: 1.0,
1470 },
1471 )
1472 .unwrap_or_else(|_| panic!("JPS planner should build from {name} obstacles"));
1473
1474 (map, scenarios, a_star, jps)
1475 }
1476
1477 fn build_sampled_moving_ai_bucket_cases(
1478 name: &str,
1479 map: &MovingAiMap,
1480 scenarios: &[MovingAiScenario],
1481 bucket: u32,
1482 sample_slots: &[usize],
1483 ) -> Vec<(usize, Point2D, Point2D, f64)> {
1484 let bucket_scenarios: Vec<&MovingAiScenario> = scenarios
1485 .iter()
1486 .filter(|row| row.bucket == bucket)
1487 .collect();
1488 assert!(
1489 !bucket_scenarios.is_empty(),
1490 "{name} MovingAI bucket {bucket} scenarios should exist"
1491 );
1492 let max_slot = sample_slots.iter().copied().max().unwrap_or(0);
1493 assert!(
1494 bucket_scenarios.len() > max_slot,
1495 "{name} bucket {bucket} should have at least {} scenarios, found {}",
1496 max_slot + 1,
1497 bucket_scenarios.len()
1498 );
1499
1500 sample_slots
1501 .iter()
1502 .map(|&slot| {
1503 let scenario = bucket_scenarios[slot];
1504 let start = map
1505 .planning_point(scenario.start_x, scenario.start_y)
1506 .unwrap_or_else(|_| panic!("{name} start should be valid"));
1507 let goal = map
1508 .planning_point(scenario.goal_x, scenario.goal_y)
1509 .unwrap_or_else(|_| panic!("{name} goal should be valid"));
1510 (slot, start, goal, scenario.optimal_length)
1511 })
1512 .collect()
1513 }
1514
1515 fn measure_planner_median_us<F>(iterations: usize, mut plan: F) -> f64
1516 where
1517 F: FnMut() -> Path2D,
1518 {
1519 let mut samples = Vec::with_capacity(iterations);
1520 for _ in 0..iterations {
1521 let started = Instant::now();
1522 let path = plan();
1523 black_box(path.points.len());
1524 black_box(path.total_length());
1525 samples.push(started.elapsed().as_secs_f64() * 1_000_000.0);
1526 }
1527 samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
1528 samples[iterations / 2]
1529 }
1530
1531 fn median_value(mut samples: Vec<f64>) -> f64 {
1532 assert!(
1533 !samples.is_empty(),
1534 "median_value requires at least one sample"
1535 );
1536 samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
1537 samples[samples.len() / 2]
1538 }
1539
1540 #[derive(Debug, Clone, Copy)]
1541 struct BucketRuntimeAggregate {
1542 a_star_median_us: f64,
1543 jps_median_us: f64,
1544 a_star_min_us: f64,
1545 a_star_max_us: f64,
1546 jps_min_us: f64,
1547 jps_max_us: f64,
1548 jps_wins: usize,
1549 sample_count: usize,
1550 }
1551
1552 #[allow(clippy::too_many_arguments)]
1553 fn measure_moving_ai_bucket_runtime_aggregate(
1554 name: &str,
1555 map: &MovingAiMap,
1556 scenarios: &[MovingAiScenario],
1557 a_star: &AStarPlanner,
1558 jps: &JPSPlanner,
1559 bucket: u32,
1560 sample_slots: &[usize],
1561 iterations: usize,
1562 ) -> BucketRuntimeAggregate {
1563 let sampled_cases =
1564 build_sampled_moving_ai_bucket_cases(name, map, scenarios, bucket, sample_slots);
1565 let mut a_star_samples = Vec::with_capacity(sampled_cases.len());
1566 let mut jps_samples = Vec::with_capacity(sampled_cases.len());
1567 let mut jps_wins = 0usize;
1568
1569 for (slot, start, goal, optimal_length) in sampled_cases {
1570 let warmup_a = a_star
1571 .plan(start, goal)
1572 .unwrap_or_else(|_| panic!("A* should solve {name} bucket {bucket} slot {slot}"));
1573 assert!(
1574 (warmup_a.total_length() - optimal_length).abs() <= 1e-6,
1575 "A* warmup path for {name} bucket {bucket} slot {slot} should match MovingAI optimal {}",
1576 optimal_length
1577 );
1578 let warmup_j = jps
1579 .plan(start, goal)
1580 .unwrap_or_else(|_| panic!("JPS should solve {name} bucket {bucket} slot {slot}"));
1581 assert!(
1582 (warmup_j.total_length() - optimal_length).abs() <= 1e-6,
1583 "JPS warmup path for {name} bucket {bucket} slot {slot} should match MovingAI optimal {}",
1584 optimal_length
1585 );
1586
1587 let a_star_median_us = measure_planner_median_us(iterations, || {
1588 let path = a_star.plan(start, goal).unwrap_or_else(|_| {
1589 panic!("A* should solve {name} bucket {bucket} slot {slot}")
1590 });
1591 assert!(
1592 (path.total_length() - optimal_length).abs() <= 1e-6,
1593 "A* path length {} should match MovingAI optimal {} on {name} bucket {bucket} slot {slot}",
1594 path.total_length(),
1595 optimal_length
1596 );
1597 path
1598 });
1599 let jps_median_us = measure_planner_median_us(iterations, || {
1600 let path = jps.plan(start, goal).unwrap_or_else(|_| {
1601 panic!("JPS should solve {name} bucket {bucket} slot {slot}")
1602 });
1603 assert!(
1604 (path.total_length() - optimal_length).abs() <= 1e-6,
1605 "JPS path length {} should match MovingAI optimal {} on {name} bucket {bucket} slot {slot}",
1606 path.total_length(),
1607 optimal_length
1608 );
1609 path
1610 });
1611 if jps_median_us < a_star_median_us {
1612 jps_wins += 1;
1613 }
1614 a_star_samples.push(a_star_median_us);
1615 jps_samples.push(jps_median_us);
1616 }
1617
1618 let a_star_median_us = median_value(a_star_samples.clone());
1619 let jps_median_us = median_value(jps_samples.clone());
1620 let a_star_min_us = a_star_samples.iter().copied().fold(f64::INFINITY, f64::min);
1621 let a_star_max_us = a_star_samples
1622 .iter()
1623 .copied()
1624 .fold(f64::NEG_INFINITY, f64::max);
1625 let jps_min_us = jps_samples.iter().copied().fold(f64::INFINITY, f64::min);
1626 let jps_max_us = jps_samples
1627 .iter()
1628 .copied()
1629 .fold(f64::NEG_INFINITY, f64::max);
1630
1631 BucketRuntimeAggregate {
1632 a_star_median_us,
1633 jps_median_us,
1634 a_star_min_us,
1635 a_star_max_us,
1636 jps_min_us,
1637 jps_max_us,
1638 jps_wins,
1639 sample_count: sample_slots.len(),
1640 }
1641 }
1642
1643 fn create_small_obstacles() -> (Vec<f64>, Vec<f64>) {
1644 let mut ox = Vec::new();
1645 let mut oy = Vec::new();
1646
1647 for i in 0..11 {
1648 ox.push(i as f64);
1649 oy.push(0.0);
1650 ox.push(i as f64);
1651 oy.push(10.0);
1652 ox.push(0.0);
1653 oy.push(i as f64);
1654 ox.push(10.0);
1655 oy.push(i as f64);
1656 }
1657
1658 for i in 4..7 {
1659 ox.push(5.0);
1660 oy.push(i as f64);
1661 }
1662
1663 (ox, oy)
1664 }
1665
1666 fn draw_horizontal_line(
1667 start_x: i32,
1668 start_y: i32,
1669 length: i32,
1670 ox: &mut Vec<f64>,
1671 oy: &mut Vec<f64>,
1672 ) {
1673 for i in start_x..start_x + length {
1674 for j in start_y..start_y + 2 {
1675 ox.push(i as f64);
1676 oy.push(j as f64);
1677 }
1678 }
1679 }
1680
1681 fn draw_vertical_line(
1682 start_x: i32,
1683 start_y: i32,
1684 length: i32,
1685 ox: &mut Vec<f64>,
1686 oy: &mut Vec<f64>,
1687 ) {
1688 for i in start_x..start_x + 2 {
1689 for j in start_y..start_y + length {
1690 ox.push(i as f64);
1691 oy.push(j as f64);
1692 }
1693 }
1694 }
1695
1696 fn create_upstream_jump_point_maze() -> (Vec<f64>, Vec<f64>) {
1697 let mut ox = Vec::new();
1698 let mut oy = Vec::new();
1699
1700 draw_vertical_line(0, 0, 50, &mut ox, &mut oy);
1701 draw_vertical_line(48, 0, 50, &mut ox, &mut oy);
1702 draw_horizontal_line(0, 0, 50, &mut ox, &mut oy);
1703 draw_horizontal_line(0, 48, 50, &mut ox, &mut oy);
1704
1705 let vertical_x = [10, 10, 10, 15, 20, 20, 30, 30, 35, 30, 40, 45];
1706 let vertical_y = [10, 30, 45, 20, 5, 40, 10, 40, 5, 40, 10, 25];
1707 let vertical_len = [10, 10, 5, 10, 10, 5, 20, 10, 25, 10, 35, 15];
1708 for ((x, y), len) in vertical_x
1709 .iter()
1710 .zip(vertical_y.iter())
1711 .zip(vertical_len.iter())
1712 {
1713 draw_vertical_line(*x, *y, *len, &mut ox, &mut oy);
1714 }
1715
1716 let horizontal_x = [35, 40, 15, 10, 45, 20, 10, 15, 25, 45, 10, 30, 10, 40];
1717 let horizontal_y = [5, 10, 15, 20, 20, 25, 30, 35, 35, 35, 40, 40, 45, 45];
1718 let horizontal_len = [10, 5, 10, 10, 5, 5, 10, 5, 10, 5, 10, 5, 5, 5];
1719 for ((x, y), len) in horizontal_x
1720 .iter()
1721 .zip(horizontal_y.iter())
1722 .zip(horizontal_len.iter())
1723 {
1724 draw_horizontal_line(*x, *y, *len, &mut ox, &mut oy);
1725 }
1726
1727 (ox, oy)
1728 }
1729
1730 fn upstream_jump_point_reference_polyline() -> Vec<Point2D> {
1731 vec![
1732 Point2D::new(5.0, 5.0),
1733 Point2D::new(7.0, 6.0),
1734 Point2D::new(9.0, 10.0),
1735 Point2D::new(9.0, 15.0),
1736 Point2D::new(9.0, 20.0),
1737 Point2D::new(10.0, 22.0),
1738 Point2D::new(11.0, 26.0),
1739 Point2D::new(10.0, 29.0),
1740 Point2D::new(9.0, 33.0),
1741 Point2D::new(9.0, 38.0),
1742 Point2D::new(10.0, 42.0),
1743 Point2D::new(14.0, 44.0),
1744 Point2D::new(19.0, 44.0),
1745 Point2D::new(22.0, 47.0),
1746 Point2D::new(25.0, 45.0),
1747 Point2D::new(29.0, 43.0),
1748 Point2D::new(29.0, 40.0),
1749 Point2D::new(32.0, 39.0),
1750 Point2D::new(35.0, 40.0),
1751 Point2D::new(36.0, 44.0),
1752 Point2D::new(35.0, 45.0),
1753 ]
1754 }
1755
1756 fn polyline_length(points: &[Point2D]) -> f64 {
1757 points
1758 .windows(2)
1759 .map(|window| window[0].distance(&window[1]))
1760 .sum()
1761 }
1762
1763 #[test]
1764 fn test_jps_finds_path() {
1765 let (ox, oy) = create_simple_obstacles();
1766 let planner = JPSPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
1767
1768 let start = Point2D::new(10.0, 10.0);
1769 let goal = Point2D::new(50.0, 50.0);
1770
1771 let result = planner.plan(start, goal);
1772 assert!(result.is_ok());
1773
1774 let path = result.unwrap();
1775 assert!(!path.is_empty());
1776
1777 let first = &path.points[0];
1778 let last = &path.points[path.len() - 1];
1779 assert!((first.x - 10.0).abs() < 2.0);
1780 assert!((first.y - 10.0).abs() < 2.0);
1781 assert!((last.x - 50.0).abs() < 2.0);
1782 assert!((last.y - 50.0).abs() < 2.0);
1783 }
1784
1785 #[test]
1786 fn test_jps_small_map() {
1787 let (ox, oy) = create_small_obstacles();
1788 let planner = JPSPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
1789
1790 let start = Point2D::new(2.0, 2.0);
1791 let goal = Point2D::new(8.0, 8.0);
1792
1793 let result = planner.plan(start, goal);
1794 assert!(result.is_ok());
1795
1796 let path = result.unwrap();
1797 assert!(!path.is_empty());
1798 }
1799
1800 #[test]
1801 fn test_jps_diagnostics_are_consistent_on_small_map() {
1802 let (ox, oy) = create_small_obstacles();
1803 let planner = JPSPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
1804
1805 let result = planner
1806 .plan_with_diagnostics(Point2D::new(2.0, 2.0), Point2D::new(8.0, 8.0))
1807 .expect("small-map JPS query should succeed");
1808
1809 assert!(!result.path.is_empty());
1810 assert_eq!(
1811 result.diagnostics.used_fallback,
1812 result.diagnostics.fallback_reason.is_some()
1813 );
1814 if result.diagnostics.fallback_reason != Some(JPSFallbackReason::InvalidJumpPath) {
1815 assert_eq!(result.diagnostics.invalid_jump_path_detail, None);
1816 assert_eq!(result.diagnostics.cost_mismatch_metrics, None);
1817 }
1818 if !matches!(
1819 result.diagnostics.invalid_jump_path_detail,
1820 Some(JPSInvalidJumpPathDetail::CostMismatch)
1821 | Some(JPSInvalidJumpPathDetail::InvalidStepSequenceAndCostMismatch)
1822 ) {
1823 assert_eq!(result.diagnostics.cost_mismatch_metrics, None);
1824 }
1825 }
1826
1827 #[test]
1828 #[allow(deprecated)]
1829 fn test_jps_legacy_interface() {
1830 let (ox, oy) = create_small_obstacles();
1831 let planner = JPSPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
1832
1833 let result = planner.planning(2.0, 2.0, 8.0, 8.0);
1834 assert!(result.is_some());
1835
1836 let (rx, ry) = result.unwrap();
1837 assert!(!rx.is_empty());
1838 assert_eq!(rx.len(), ry.len());
1839 }
1840
1841 #[test]
1842 fn test_jps_no_path() {
1843 let mut ox = Vec::new();
1844 let mut oy = Vec::new();
1845
1846 for i in 0..10 {
1847 ox.push(i as f64);
1848 oy.push(0.0);
1849 ox.push(i as f64);
1850 oy.push(9.0);
1851 ox.push(0.0);
1852 oy.push(i as f64);
1853 ox.push(9.0);
1854 oy.push(i as f64);
1855 }
1856
1857 for i in 1..9 {
1858 ox.push(5.0);
1859 oy.push(i as f64);
1860 }
1861
1862 let planner = JPSPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
1863
1864 let start = Point2D::new(2.0, 5.0);
1865 let goal = Point2D::new(7.0, 5.0);
1866
1867 let result = planner.plan(start, goal);
1868 assert!(result.is_err());
1869 }
1870
1871 #[test]
1872 fn test_jps_diagonal_path() {
1873 let mut ox = Vec::new();
1874 let mut oy = Vec::new();
1875
1876 for i in 0..21 {
1877 ox.push(i as f64);
1878 oy.push(0.0);
1879 ox.push(i as f64);
1880 oy.push(20.0);
1881 ox.push(0.0);
1882 oy.push(i as f64);
1883 ox.push(20.0);
1884 oy.push(i as f64);
1885 }
1886
1887 let planner = JPSPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
1888
1889 let start = Point2D::new(2.0, 2.0);
1890 let goal = Point2D::new(18.0, 18.0);
1891
1892 let result = planner.plan(start, goal);
1893 assert!(result.is_ok());
1894
1895 let path = result.unwrap();
1896 assert!(path.len() >= 2, "Path should have at least start and goal");
1897 let total_len = path.total_length();
1898 assert!(
1899 total_len < 30.0,
1900 "Path should be efficient, got length {}",
1901 total_len
1902 );
1903 }
1904
1905 #[test]
1906 fn test_jps_from_obstacle_points() {
1907 let (ox, oy) = create_small_obstacles();
1908 let obstacles = Obstacles::try_from_xy(&ox, &oy).unwrap();
1909 let planner = JPSPlanner::from_obstacle_points(&obstacles, JPSConfig::default()).unwrap();
1910
1911 let path = planner.plan_xy(2.0, 2.0, 8.0, 8.0).unwrap();
1912 assert!(!path.is_empty());
1913 }
1914
1915 #[test]
1916 fn test_jps_try_new_rejects_invalid_config() {
1917 let (ox, oy) = create_small_obstacles();
1918 let config = JPSConfig {
1919 heuristic_weight: 0.0,
1920 ..Default::default()
1921 };
1922
1923 let err = match JPSPlanner::try_new(&ox, &oy, config) {
1924 Ok(_) => panic!("expected invalid config to be rejected"),
1925 Err(err) => err,
1926 };
1927 assert!(matches!(err, RoboticsError::InvalidParameter(_)));
1928 }
1929
1930 #[test]
1931 fn test_jps_preserves_asymmetric_world_coordinates() {
1932 let mut obstacles = Obstacles::new();
1933
1934 for x in 10..=20 {
1935 obstacles.push(Point2D::new(x as f64, -4.0));
1936 obstacles.push(Point2D::new(x as f64, 6.0));
1937 }
1938 for y in -4..=6 {
1939 obstacles.push(Point2D::new(10.0, y as f64));
1940 obstacles.push(Point2D::new(20.0, y as f64));
1941 }
1942
1943 let planner = JPSPlanner::from_obstacle_points(&obstacles, JPSConfig::default()).unwrap();
1944 let path = planner.plan_xy(12.0, -2.0, 18.0, 4.0).unwrap();
1945
1946 let first = path.points.first().unwrap();
1947 let last = path.points.last().unwrap();
1948 assert!((first.x - 12.0).abs() < 1e-6);
1949 assert!((first.y + 2.0).abs() < 1e-6);
1950 assert!((last.x - 18.0).abs() < 1e-6);
1951 assert!((last.y - 4.0).abs() < 1e-6);
1952 }
1953
1954 #[test]
1955 fn test_jps_solves_upstream_jump_point_maze() {
1956 let (ox, oy) = create_upstream_jump_point_maze();
1957 let planner = JPSPlanner::from_obstacles(&ox, &oy, 1.0, 0.0);
1958
1959 let path = planner
1960 .plan(Point2D::new(5.0, 5.0), Point2D::new(35.0, 45.0))
1961 .unwrap();
1962
1963 let first = path.points.first().unwrap();
1964 let last = path.points.last().unwrap();
1965 assert!((first.x - 5.0).abs() < 1e-6);
1966 assert!((first.y - 5.0).abs() < 1e-6);
1967 assert!((last.x - 35.0).abs() < 1e-6);
1968 assert!((last.y - 45.0).abs() < 1e-6);
1969
1970 let upstream_reference_length = polyline_length(&upstream_jump_point_reference_polyline());
1971 assert!(
1972 path.total_length() <= upstream_reference_length + 1e-6,
1973 "Rust JPS path should stay no worse than PythonRobotics jump-point variant reference, got {} vs {}",
1974 path.total_length(),
1975 upstream_reference_length,
1976 );
1977
1978 let grid = planner.grid_map();
1979 for point in &path.points {
1980 let ix = grid.calc_x_index(point.x);
1981 let iy = grid.calc_y_index(point.y);
1982 assert!(
1983 grid.is_valid(ix, iy),
1984 "path point ({}, {}) should stay collision-free",
1985 point.x,
1986 point.y
1987 );
1988 }
1989 }
1990
1991 #[test]
1992 #[ignore = "long-running MovingAI benchmark"]
1993 fn test_jps_solves_moving_ai_arena2_bucket_80() {
1994 assert_pure_jps_matches_moving_ai_bucket(
1995 "arena2",
1996 include_str!("../benchdata/moving_ai/dao/arena2.map"),
1997 include_str!("../benchdata/moving_ai/dao/arena2.map.scen"),
1998 80,
1999 );
2000 }
2001
2002 #[test]
2003 #[ignore = "long-running MovingAI benchmark"]
2004 fn test_jps_reports_cost_mismatch_metrics_on_moving_ai_bucket_80_subset() {
2005 for (name, map_str, scen_str) in MOVING_AI_CASES {
2006 assert_pure_jps_matches_moving_ai_bucket(name, map_str, scen_str, 80);
2007 }
2008 }
2009
2010 #[test]
2011 #[ignore = "long-running MovingAI benchmark"]
2012 fn test_jps_solves_sampled_moving_ai_buckets_without_fallback() {
2013 const BUCKETS: [u32; 5] = [0, 20, 40, 60, 80];
2014
2015 for (name, map_str, scen_str) in MOVING_AI_CASES {
2016 for bucket in BUCKETS {
2017 assert_pure_jps_matches_moving_ai_bucket(name, map_str, scen_str, bucket);
2018 }
2019 }
2020 }
2021
2022 #[test]
2023 #[ignore = "long-running MovingAI benchmark"]
2024 fn test_jps_solves_long_tail_moving_ai_buckets_without_fallback() {
2025 let cases: [(&str, &str, &str, &[u32]); 3] = [
2026 (
2027 "arena2",
2028 include_str!("../benchdata/moving_ai/dao/arena2.map"),
2029 include_str!("../benchdata/moving_ai/dao/arena2.map.scen"),
2030 &[90],
2031 ),
2032 (
2033 "8room_000",
2034 include_str!("../benchdata/moving_ai/room/8room_000.map"),
2035 include_str!("../benchdata/moving_ai/room/8room_000.map.scen"),
2036 &[120, 160, 213],
2037 ),
2038 (
2039 "random512-10-0",
2040 include_str!("../benchdata/moving_ai/random/random512-10-0.map"),
2041 include_str!("../benchdata/moving_ai/random/random512-10-0.map.scen"),
2042 &[100, 140, 177],
2043 ),
2044 ];
2045
2046 for (name, map_str, scen_str, buckets) in cases {
2047 for bucket in buckets {
2048 assert_pure_jps_matches_moving_ai_bucket(name, map_str, scen_str, *bucket);
2049 }
2050 }
2051 }
2052
2053 #[test]
2054 #[ignore = "long-running MovingAI benchmark"]
2055 fn test_jps_solves_sampled_moving_ai_maze_buckets_without_fallback() {
2056 const BUCKETS: [u32; 6] = [0, 20, 40, 60, 80, 120];
2057
2058 for bucket in BUCKETS {
2059 assert_pure_jps_matches_moving_ai_bucket(
2060 "maze512-1-0",
2061 include_str!("../benchdata/moving_ai/maze/maze512-1-0.map"),
2062 include_str!("../benchdata/moving_ai/maze/maze512-1-0.map.scen"),
2063 bucket,
2064 );
2065 }
2066 }
2067
2068 #[test]
2069 #[ignore = "long-running MovingAI benchmark"]
2070 fn test_jps_solves_long_tail_moving_ai_maze_buckets_without_fallback() {
2071 const BUCKETS: [u32; 4] = [200, 400, 800, 1211];
2072
2073 for bucket in BUCKETS {
2074 assert_pure_jps_matches_moving_ai_bucket(
2075 "maze512-1-0",
2076 include_str!("../benchdata/moving_ai/maze/maze512-1-0.map"),
2077 include_str!("../benchdata/moving_ai/maze/maze512-1-0.map.scen"),
2078 bucket,
2079 );
2080 }
2081 }
2082
2083 #[test]
2084 #[ignore = "long-running MovingAI benchmark"]
2085 fn test_jps_solves_sampled_moving_ai_street_buckets_without_fallback() {
2086 const BUCKETS: [u32; 8] = [0, 20, 40, 60, 80, 120, 160, 186];
2087
2088 for bucket in BUCKETS {
2089 assert_pure_jps_matches_moving_ai_bucket(
2090 "Berlin_0_512",
2091 include_str!("../benchdata/moving_ai/street/Berlin_0_512.map"),
2092 include_str!("../benchdata/moving_ai/street/Berlin_0_512.map.scen"),
2093 bucket,
2094 );
2095 }
2096 }
2097
2098 #[test]
2099 #[ignore = "long-running MovingAI benchmark"]
2100 fn test_jps_vs_a_star_runtime_on_long_tail_moving_ai_subset() {
2101 const ITERATIONS: usize = 7;
2102 let cases: [(&str, &str, &str, u32); 5] = [
2103 (
2104 "arena2",
2105 include_str!("../benchdata/moving_ai/dao/arena2.map"),
2106 include_str!("../benchdata/moving_ai/dao/arena2.map.scen"),
2107 90,
2108 ),
2109 (
2110 "8room_000",
2111 include_str!("../benchdata/moving_ai/room/8room_000.map"),
2112 include_str!("../benchdata/moving_ai/room/8room_000.map.scen"),
2113 213,
2114 ),
2115 (
2116 "random512-10-0",
2117 include_str!("../benchdata/moving_ai/random/random512-10-0.map"),
2118 include_str!("../benchdata/moving_ai/random/random512-10-0.map.scen"),
2119 177,
2120 ),
2121 (
2122 "maze512-1-0",
2123 include_str!("../benchdata/moving_ai/maze/maze512-1-0.map"),
2124 include_str!("../benchdata/moving_ai/maze/maze512-1-0.map.scen"),
2125 1211,
2126 ),
2127 (
2128 "Berlin_0_512",
2129 include_str!("../benchdata/moving_ai/street/Berlin_0_512.map"),
2130 include_str!("../benchdata/moving_ai/street/Berlin_0_512.map.scen"),
2131 186,
2132 ),
2133 ];
2134
2135 for (name, map_str, scen_str, bucket) in cases {
2136 let (a_star, jps, start, goal, optimal_length) =
2137 build_moving_ai_bucket_planners(name, map_str, scen_str, bucket);
2138
2139 let warmup_a = a_star
2140 .plan(start, goal)
2141 .unwrap_or_else(|_| panic!("A* should solve {name} bucket {bucket}"));
2142 assert!(
2143 (warmup_a.total_length() - optimal_length).abs() <= 1e-6,
2144 "A* warmup path for {name} bucket {bucket} should match MovingAI optimal {}",
2145 optimal_length
2146 );
2147 let warmup_j = jps
2148 .plan(start, goal)
2149 .unwrap_or_else(|_| panic!("JPS should solve {name} bucket {bucket}"));
2150 assert!(
2151 (warmup_j.total_length() - optimal_length).abs() <= 1e-6,
2152 "JPS warmup path for {name} bucket {bucket} should match MovingAI optimal {}",
2153 optimal_length
2154 );
2155
2156 let a_star_median_us = measure_planner_median_us(ITERATIONS, || {
2157 let path = a_star
2158 .plan(start, goal)
2159 .unwrap_or_else(|_| panic!("A* should solve {name} bucket {bucket}"));
2160 assert!(
2161 (path.total_length() - optimal_length).abs() <= 1e-6,
2162 "A* path length {} should match MovingAI optimal {} on {name} bucket {bucket}",
2163 path.total_length(),
2164 optimal_length
2165 );
2166 path
2167 });
2168 let jps_median_us = measure_planner_median_us(ITERATIONS, || {
2169 let path = jps
2170 .plan(start, goal)
2171 .unwrap_or_else(|_| panic!("JPS should solve {name} bucket {bucket}"));
2172 assert!(
2173 (path.total_length() - optimal_length).abs() <= 1e-6,
2174 "JPS path length {} should match MovingAI optimal {} on {name} bucket {bucket}",
2175 path.total_length(),
2176 optimal_length
2177 );
2178 path
2179 });
2180 println!(
2181 "{name}/bucket{bucket}: a_star_median_us={:.2}, jps_median_us={:.2}, a_over_j={:.3}",
2182 a_star_median_us,
2183 jps_median_us,
2184 a_star_median_us / jps_median_us
2185 );
2186 }
2187 }
2188
2189 #[test]
2190 #[ignore = "long-running MovingAI benchmark"]
2191 fn test_jps_vs_a_star_runtime_crossover_samples_on_moving_ai() {
2192 const ITERATIONS: usize = 5;
2193 let families: [(&str, &str, &str, &[u32]); 5] = [
2194 (
2195 "arena2",
2196 include_str!("../benchdata/moving_ai/dao/arena2.map"),
2197 include_str!("../benchdata/moving_ai/dao/arena2.map.scen"),
2198 &[70, 75, 80, 85, 90],
2199 ),
2200 (
2201 "8room_000",
2202 include_str!("../benchdata/moving_ai/room/8room_000.map"),
2203 include_str!("../benchdata/moving_ai/room/8room_000.map.scen"),
2204 &[80, 120, 160, 213],
2205 ),
2206 (
2207 "random512-10-0",
2208 include_str!("../benchdata/moving_ai/random/random512-10-0.map"),
2209 include_str!("../benchdata/moving_ai/random/random512-10-0.map.scen"),
2210 &[80, 90, 100, 120, 140, 160, 177],
2211 ),
2212 (
2213 "maze512-1-0",
2214 include_str!("../benchdata/moving_ai/maze/maze512-1-0.map"),
2215 include_str!("../benchdata/moving_ai/maze/maze512-1-0.map.scen"),
2216 &[80, 120, 200, 400, 800, 1211],
2217 ),
2218 (
2219 "Berlin_0_512",
2220 include_str!("../benchdata/moving_ai/street/Berlin_0_512.map"),
2221 include_str!("../benchdata/moving_ai/street/Berlin_0_512.map.scen"),
2222 &[80, 120, 160, 186],
2223 ),
2224 ];
2225
2226 for (name, map_str, scen_str, buckets) in families {
2227 for bucket in buckets {
2228 let (a_star, jps, start, goal, optimal_length) =
2229 build_moving_ai_bucket_planners(name, map_str, scen_str, *bucket);
2230
2231 let warmup_a = a_star
2232 .plan(start, goal)
2233 .unwrap_or_else(|_| panic!("A* should solve {name} bucket {bucket}"));
2234 assert!(
2235 (warmup_a.total_length() - optimal_length).abs() <= 1e-6,
2236 "A* warmup path for {name} bucket {bucket} should match MovingAI optimal {}",
2237 optimal_length
2238 );
2239 let warmup_j = jps
2240 .plan(start, goal)
2241 .unwrap_or_else(|_| panic!("JPS should solve {name} bucket {bucket}"));
2242 assert!(
2243 (warmup_j.total_length() - optimal_length).abs() <= 1e-6,
2244 "JPS warmup path for {name} bucket {bucket} should match MovingAI optimal {}",
2245 optimal_length
2246 );
2247
2248 let a_star_median_us = measure_planner_median_us(ITERATIONS, || {
2249 let path = a_star
2250 .plan(start, goal)
2251 .unwrap_or_else(|_| panic!("A* should solve {name} bucket {bucket}"));
2252 assert!(
2253 (path.total_length() - optimal_length).abs() <= 1e-6,
2254 "A* path length {} should match MovingAI optimal {} on {name} bucket {bucket}",
2255 path.total_length(),
2256 optimal_length
2257 );
2258 path
2259 });
2260 let jps_median_us = measure_planner_median_us(ITERATIONS, || {
2261 let path = jps
2262 .plan(start, goal)
2263 .unwrap_or_else(|_| panic!("JPS should solve {name} bucket {bucket}"));
2264 assert!(
2265 (path.total_length() - optimal_length).abs() <= 1e-6,
2266 "JPS path length {} should match MovingAI optimal {} on {name} bucket {bucket}",
2267 path.total_length(),
2268 optimal_length
2269 );
2270 path
2271 });
2272 println!(
2273 "{name}/bucket{bucket}: a_star_median_us={:.2}, jps_median_us={:.2}, a_over_j={:.3}",
2274 a_star_median_us,
2275 jps_median_us,
2276 a_star_median_us / jps_median_us
2277 );
2278 }
2279 }
2280 }
2281
2282 #[test]
2283 #[ignore = "long-running MovingAI benchmark"]
2284 fn test_jps_vs_a_star_runtime_bucket_aggregate_samples_on_moving_ai() {
2285 const ITERATIONS: usize = 1;
2286 const SAMPLE_SLOTS: [usize; 3] = [0, 4, 9];
2287 let families: [(&str, &str, &str, &[u32]); 5] = [
2288 (
2289 "arena2",
2290 include_str!("../benchdata/moving_ai/dao/arena2.map"),
2291 include_str!("../benchdata/moving_ai/dao/arena2.map.scen"),
2292 &[80, 81, 82, 83, 84, 85],
2293 ),
2294 (
2295 "8room_000",
2296 include_str!("../benchdata/moving_ai/room/8room_000.map"),
2297 include_str!("../benchdata/moving_ai/room/8room_000.map.scen"),
2298 &[80, 90, 100, 110, 120],
2299 ),
2300 (
2301 "random512-10-0",
2302 include_str!("../benchdata/moving_ai/random/random512-10-0.map"),
2303 include_str!("../benchdata/moving_ai/random/random512-10-0.map.scen"),
2304 &[120, 125, 130, 135, 140],
2305 ),
2306 (
2307 "maze512-1-0",
2308 include_str!("../benchdata/moving_ai/maze/maze512-1-0.map"),
2309 include_str!("../benchdata/moving_ai/maze/maze512-1-0.map.scen"),
2310 &[80, 200, 1211],
2311 ),
2312 (
2313 "Berlin_0_512",
2314 include_str!("../benchdata/moving_ai/street/Berlin_0_512.map"),
2315 include_str!("../benchdata/moving_ai/street/Berlin_0_512.map.scen"),
2316 &[80, 100, 120, 140, 160, 180, 186],
2317 ),
2318 ];
2319
2320 for (name, map_str, scen_str, buckets) in families {
2321 let (map, scenarios, a_star, jps) =
2322 build_moving_ai_family_planners(name, map_str, scen_str);
2323
2324 for bucket in buckets {
2325 let aggregate = measure_moving_ai_bucket_runtime_aggregate(
2326 name,
2327 &map,
2328 &scenarios,
2329 &a_star,
2330 &jps,
2331 *bucket,
2332 &SAMPLE_SLOTS,
2333 ITERATIONS,
2334 );
2335 println!(
2336 "{name}/bucket{bucket}: sample_slots={:?}, a_star_bucket_median_us={:.2}, jps_bucket_median_us={:.2}, a_over_j={:.3}, jps_wins={}/{}",
2337 SAMPLE_SLOTS,
2338 aggregate.a_star_median_us,
2339 aggregate.jps_median_us,
2340 aggregate.a_star_median_us / aggregate.jps_median_us,
2341 aggregate.jps_wins,
2342 aggregate.sample_count
2343 );
2344 }
2345 }
2346 }
2347
2348 #[test]
2349 #[ignore = "long-running MovingAI benchmark"]
2350 fn test_jps_vs_a_star_runtime_bucket_full_aggregate_on_narrowed_windows() {
2351 const ITERATIONS: usize = 1;
2352 const FULL_BUCKET_SLOTS: [usize; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
2353 let families: [(&str, &str, &str, &[u32]); 4] = [
2354 (
2355 "arena2",
2356 include_str!("../benchdata/moving_ai/dao/arena2.map"),
2357 include_str!("../benchdata/moving_ai/dao/arena2.map.scen"),
2358 &[86, 87, 88, 89, 90],
2359 ),
2360 (
2361 "8room_000",
2362 include_str!("../benchdata/moving_ai/room/8room_000.map"),
2363 include_str!("../benchdata/moving_ai/room/8room_000.map.scen"),
2364 &[80, 85, 90],
2365 ),
2366 (
2367 "random512-10-0",
2368 include_str!("../benchdata/moving_ai/random/random512-10-0.map"),
2369 include_str!("../benchdata/moving_ai/random/random512-10-0.map.scen"),
2370 &[130, 132, 135],
2371 ),
2372 (
2373 "Berlin_0_512",
2374 include_str!("../benchdata/moving_ai/street/Berlin_0_512.map"),
2375 include_str!("../benchdata/moving_ai/street/Berlin_0_512.map.scen"),
2376 &[120, 140, 160, 186],
2377 ),
2378 ];
2379
2380 for (name, map_str, scen_str, buckets) in families {
2381 let (map, scenarios, a_star, jps) =
2382 build_moving_ai_family_planners(name, map_str, scen_str);
2383
2384 for bucket in buckets {
2385 let aggregate = measure_moving_ai_bucket_runtime_aggregate(
2386 name,
2387 &map,
2388 &scenarios,
2389 &a_star,
2390 &jps,
2391 *bucket,
2392 &FULL_BUCKET_SLOTS,
2393 ITERATIONS,
2394 );
2395 println!(
2396 "{name}/bucket{bucket}: full_bucket_slots=0..9, a_star_bucket_median_us={:.2}, jps_bucket_median_us={:.2}, a_over_j={:.3}, a_star_range_us=[{:.2}, {:.2}], jps_range_us=[{:.2}, {:.2}], jps_wins={}/{}",
2397 aggregate.a_star_median_us,
2398 aggregate.jps_median_us,
2399 aggregate.a_star_median_us / aggregate.jps_median_us,
2400 aggregate.a_star_min_us,
2401 aggregate.a_star_max_us,
2402 aggregate.jps_min_us,
2403 aggregate.jps_max_us,
2404 aggregate.jps_wins,
2405 aggregate.sample_count
2406 );
2407 }
2408 }
2409 }
2410}