Skip to main content

rust_robotics_planning/
sipp.rs

1//! Safe Interval Path Planning (SIPP)
2//!
3//! Plans paths in space-time by computing "safe intervals" at each grid cell
4//! and searching over (cell, interval) pairs. This avoids expanding every
5//! discrete timestep and efficiently handles dynamic obstacles with known
6//! trajectories.
7//!
8//! Reference: Phillips & Likhachev, "SIPP: Safe Interval Path Planning for
9//! Dynamic Environments", ICRA 2011.
10
11use std::cmp::Ordering;
12use std::collections::{BinaryHeap, HashMap};
13
14use rust_robotics_core::{RoboticsError, RoboticsResult};
15
16/// A half-open time interval `[start, end)`. `end == u64::MAX` means unbounded.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct Interval {
19    pub start: u64,
20    pub end: u64,
21}
22
23impl Interval {
24    pub fn new(start: u64, end: u64) -> Self {
25        Self { start, end }
26    }
27
28    /// An interval covering all time.
29    pub fn infinite() -> Self {
30        Self {
31            start: 0,
32            end: u64::MAX,
33        }
34    }
35
36    pub fn contains(&self, t: u64) -> bool {
37        t >= self.start && t < self.end
38    }
39
40    pub fn is_empty(&self) -> bool {
41        self.start >= self.end
42    }
43}
44
45/// A dynamic obstacle occupying a cell during a time interval.
46#[derive(Debug, Clone)]
47pub struct DynamicObstacle {
48    pub x: i32,
49    pub y: i32,
50    /// Half-open interval during which the cell is blocked.
51    pub interval: Interval,
52}
53
54/// Configuration for the SIPP planner.
55#[derive(Debug, Clone)]
56pub struct SippConfig {
57    pub width: i32,
58    pub height: i32,
59    /// Static obstacle map: `true` means blocked.
60    pub obstacle_map: Vec<Vec<bool>>,
61    /// Dynamic obstacles with known trajectories.
62    pub dynamic_obstacles: Vec<DynamicObstacle>,
63    /// Whether to allow diagonal (8-connected) movement.
64    pub allow_diagonal: bool,
65}
66
67/// A waypoint in the planned path, including the arrival time.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct TimedWaypoint {
70    pub x: i32,
71    pub y: i32,
72    pub t: u64,
73}
74
75/// Result of SIPP planning.
76pub type SippPath = Vec<TimedWaypoint>;
77
78// ---------------------------------------------------------------------------
79// Internal types
80// ---------------------------------------------------------------------------
81
82/// Key for identifying a search state: grid cell + interval index at that cell.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
84struct StateKey {
85    x: i32,
86    y: i32,
87    interval_idx: usize,
88}
89
90/// Entry in the open-list priority queue.
91struct OpenEntry {
92    key: StateKey,
93    f: u64,
94}
95
96impl Eq for OpenEntry {}
97impl PartialEq for OpenEntry {
98    fn eq(&self, other: &Self) -> bool {
99        self.f == other.f
100    }
101}
102impl Ord for OpenEntry {
103    fn cmp(&self, other: &Self) -> Ordering {
104        // min-heap: reverse ordering
105        other.f.cmp(&self.f)
106    }
107}
108impl PartialOrd for OpenEntry {
109    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
110        Some(self.cmp(other))
111    }
112}
113
114// ---------------------------------------------------------------------------
115// Planner
116// ---------------------------------------------------------------------------
117
118/// Safe Interval Path Planning (SIPP) planner.
119pub struct SippPlanner {
120    width: i32,
121    height: i32,
122    obstacle_map: Vec<Vec<bool>>,
123    /// For each cell (flattened by y * width + x), the sorted list of safe intervals.
124    safe_intervals: Vec<Vec<Interval>>,
125    motions: Vec<(i32, i32, u64)>,
126}
127
128impl SippPlanner {
129    /// Build a new SIPP planner from a configuration.
130    pub fn new(config: SippConfig) -> RoboticsResult<Self> {
131        if config.width <= 0 || config.height <= 0 {
132            return Err(RoboticsError::InvalidParameter(
133                "width and height must be positive".to_string(),
134            ));
135        }
136        if config.obstacle_map.len() != config.width as usize {
137            return Err(RoboticsError::InvalidParameter(format!(
138                "obstacle_map x-dimension ({}) must match width ({})",
139                config.obstacle_map.len(),
140                config.width
141            )));
142        }
143        for col in &config.obstacle_map {
144            if col.len() != config.height as usize {
145                return Err(RoboticsError::InvalidParameter(
146                    "obstacle_map y-dimension must match height".to_string(),
147                ));
148            }
149        }
150
151        let motions = if config.allow_diagonal {
152            vec![
153                (1, 0, 1),
154                (0, 1, 1),
155                (-1, 0, 1),
156                (0, -1, 1),
157                (1, 1, 1),
158                (1, -1, 1),
159                (-1, 1, 1),
160                (-1, -1, 1),
161            ]
162        } else {
163            vec![(1, 0, 1), (0, 1, 1), (-1, 0, 1), (0, -1, 1)]
164        };
165
166        let safe_intervals = Self::compute_safe_intervals(
167            &config.obstacle_map,
168            &config.dynamic_obstacles,
169            config.width,
170            config.height,
171        );
172
173        Ok(Self {
174            width: config.width,
175            height: config.height,
176            obstacle_map: config.obstacle_map,
177            safe_intervals,
178            motions,
179        })
180    }
181
182    /// Plan a timed path from `(sx, sy)` at time 0 to `(gx, gy)`.
183    pub fn plan(&self, sx: i32, sy: i32, gx: i32, gy: i32) -> RoboticsResult<SippPath> {
184        self.validate_pos(sx, sy, "start")?;
185        self.validate_pos(gx, gy, "goal")?;
186
187        if self.obstacle_map[sx as usize][sy as usize] {
188            return Err(RoboticsError::PlanningError(
189                "start is inside a static obstacle".to_string(),
190            ));
191        }
192        if self.obstacle_map[gx as usize][gy as usize] {
193            return Err(RoboticsError::PlanningError(
194                "goal is inside a static obstacle".to_string(),
195            ));
196        }
197
198        let start_intervals = &self.safe_intervals[self.cell_index(sx, sy)];
199        let start_interval_idx = match start_intervals.iter().position(|iv| iv.contains(0)) {
200            Some(idx) => idx,
201            None => {
202                return Err(RoboticsError::PlanningError(
203                    "start cell is not safe at time 0".to_string(),
204                ));
205            }
206        };
207
208        let start_key = StateKey {
209            x: sx,
210            y: sy,
211            interval_idx: start_interval_idx,
212        };
213
214        let h0 = Self::heuristic(sx, sy, gx, gy);
215
216        let mut open = BinaryHeap::new();
217        let mut best_g: HashMap<StateKey, u64> = HashMap::new();
218        let mut parent_map: HashMap<StateKey, Option<StateKey>> = HashMap::new();
219
220        best_g.insert(start_key, 0);
221        parent_map.insert(start_key, None);
222        open.push(OpenEntry {
223            key: start_key,
224            f: h0,
225        });
226
227        while let Some(current) = open.pop() {
228            let g = match best_g.get(&current.key) {
229                Some(&g) => g,
230                None => continue,
231            };
232
233            // Stale entry check
234            if current.f > g + Self::heuristic(current.key.x, current.key.y, gx, gy) {
235                continue;
236            }
237
238            // Goal reached: we need the goal cell, any safe interval that contains g.
239            if current.key.x == gx && current.key.y == gy {
240                return Ok(Self::reconstruct_path(&parent_map, &best_g, current.key));
241            }
242
243            let current_interval = self.safe_intervals
244                [self.cell_index(current.key.x, current.key.y)][current.key.interval_idx];
245
246            // Expand move actions to neighboring cells
247            for &(dx, dy, cost) in &self.motions {
248                let nx = current.key.x + dx;
249                let ny = current.key.y + dy;
250
251                if !self.in_bounds(nx, ny) || self.obstacle_map[nx as usize][ny as usize] {
252                    continue;
253                }
254
255                let arrival = g + cost;
256                let neighbor_intervals = &self.safe_intervals[self.cell_index(nx, ny)];
257
258                for (ni, niv) in neighbor_intervals.iter().enumerate() {
259                    // The arrival time must fall within this safe interval.
260                    if arrival >= niv.end {
261                        continue;
262                    }
263                    // If arrival is before the interval starts, we could wait at current
264                    // cell until niv.start - cost, then move.  But we must still be in
265                    // the current safe interval at departure time.
266                    let effective_arrival = if arrival >= niv.start {
267                        arrival
268                    } else {
269                        // We need to wait at current cell until (niv.start - cost),
270                        // then depart.  Check the current interval can hold us that long.
271                        let depart = niv.start - cost;
272                        if depart < g {
273                            // Cannot depart before we arrived at current cell.
274                            continue;
275                        }
276                        if depart >= current_interval.end {
277                            // Waiting that long leaves the current safe interval.
278                            continue;
279                        }
280                        niv.start
281                    };
282
283                    let nkey = StateKey {
284                        x: nx,
285                        y: ny,
286                        interval_idx: ni,
287                    };
288
289                    if let Some(&prev_g) = best_g.get(&nkey) {
290                        if effective_arrival >= prev_g {
291                            continue;
292                        }
293                    }
294
295                    best_g.insert(nkey, effective_arrival);
296                    parent_map.insert(nkey, Some(current.key));
297                    let f = effective_arrival + Self::heuristic(nx, ny, gx, gy);
298                    open.push(OpenEntry { key: nkey, f });
299                }
300            }
301
302            // Expand wait action: wait in the current cell and transition to the
303            // *next* safe interval at the same cell (after an unsafe gap).
304            let cell_intervals =
305                &self.safe_intervals[self.cell_index(current.key.x, current.key.y)];
306            if current.key.interval_idx + 1 < cell_intervals.len() {
307                let next_idx = current.key.interval_idx + 1;
308                let next_iv = cell_intervals[next_idx];
309                // We can reach the next interval only if the current interval's end
310                // touches the next interval's start (i.e., the gap is caused by a
311                // dynamic obstacle passing through). The robot would need to leave
312                // and come back, which is handled by move actions. But if we want to
313                // explicitly model "wait through unsafe gap at same cell", SIPP does
314                // NOT allow that -- the robot must be in a safe interval. So we skip
315                // same-cell wait-through-gap. The wait action within the CURRENT
316                // interval is already handled by the arrival-time logic above (the
317                // robot can arrive at any time within its current interval and simply
318                // wait).
319                //
320                // However, for completeness we do NOT add a same-cell transition here
321                // because the robot cannot stay at a cell during an unsafe gap.
322                let _ = (next_idx, next_iv); // suppress unused warnings
323            }
324        }
325
326        Err(RoboticsError::PlanningError("no path found".to_string()))
327    }
328
329    /// Get the safe intervals for a particular cell.
330    pub fn get_safe_intervals(&self, x: i32, y: i32) -> &[Interval] {
331        &self.safe_intervals[self.cell_index(x, y)]
332    }
333
334    // -----------------------------------------------------------------------
335    // Private helpers
336    // -----------------------------------------------------------------------
337
338    fn cell_index(&self, x: i32, y: i32) -> usize {
339        (y as usize) * (self.width as usize) + (x as usize)
340    }
341
342    fn in_bounds(&self, x: i32, y: i32) -> bool {
343        x >= 0 && y >= 0 && x < self.width && y < self.height
344    }
345
346    fn validate_pos(&self, x: i32, y: i32, label: &str) -> RoboticsResult<()> {
347        if self.in_bounds(x, y) {
348            Ok(())
349        } else {
350            Err(RoboticsError::InvalidParameter(format!(
351                "{} ({}, {}) is out of bounds ({}x{})",
352                label, x, y, self.width, self.height
353            )))
354        }
355    }
356
357    fn heuristic(x: i32, y: i32, gx: i32, gy: i32) -> u64 {
358        // Chebyshev distance (consistent with 4- and 8-connected grids with unit cost).
359        let dx = (x - gx).unsigned_abs() as u64;
360        let dy = (y - gy).unsigned_abs() as u64;
361        dx.max(dy)
362    }
363
364    /// Compute safe intervals for every cell.
365    ///
366    /// For cells that are static obstacles the list is empty (never safe).
367    /// For cells without any dynamic obstacle the list is `[0, MAX)`.
368    /// Otherwise, unsafe intervals from dynamic obstacles are merged and the
369    /// complement within `[0, MAX)` gives the safe intervals.
370    fn compute_safe_intervals(
371        obstacle_map: &[Vec<bool>],
372        dynamic_obstacles: &[DynamicObstacle],
373        width: i32,
374        height: i32,
375    ) -> Vec<Vec<Interval>> {
376        let n = (width as usize) * (height as usize);
377        let mut unsafe_map: HashMap<usize, Vec<Interval>> = HashMap::new();
378
379        for dob in dynamic_obstacles {
380            if dob.x < 0 || dob.y < 0 || dob.x >= width || dob.y >= height {
381                continue;
382            }
383            let idx = (dob.y as usize) * (width as usize) + (dob.x as usize);
384            unsafe_map.entry(idx).or_default().push(dob.interval);
385        }
386
387        let mut safe = Vec::with_capacity(n);
388        for y in 0..height {
389            for x in 0..width {
390                let idx = (y as usize) * (width as usize) + (x as usize);
391                if obstacle_map[x as usize][y as usize] {
392                    safe.push(Vec::new());
393                } else if let Some(unsafes) = unsafe_map.get(&idx) {
394                    safe.push(Self::complement_intervals(unsafes));
395                } else {
396                    safe.push(vec![Interval::infinite()]);
397                }
398            }
399        }
400        safe
401    }
402
403    /// Given a list of (possibly overlapping) unsafe intervals, return the
404    /// sorted list of safe intervals within `[0, u64::MAX)`.
405    fn complement_intervals(unsafe_intervals: &[Interval]) -> Vec<Interval> {
406        if unsafe_intervals.is_empty() {
407            return vec![Interval::infinite()];
408        }
409
410        // Merge overlapping unsafe intervals.
411        let mut sorted: Vec<(u64, u64)> = unsafe_intervals
412            .iter()
413            .filter(|iv| !iv.is_empty())
414            .map(|iv| (iv.start, iv.end))
415            .collect();
416        sorted.sort_by_key(|&(s, _)| s);
417
418        let mut merged: Vec<(u64, u64)> = Vec::new();
419        for (s, e) in sorted {
420            if let Some(last) = merged.last_mut() {
421                if s <= last.1 {
422                    last.1 = last.1.max(e);
423                    continue;
424                }
425            }
426            merged.push((s, e));
427        }
428
429        // Build complement.
430        let mut result = Vec::new();
431        let mut cursor: u64 = 0;
432        for (s, e) in &merged {
433            if cursor < *s {
434                result.push(Interval::new(cursor, *s));
435            }
436            cursor = *e;
437        }
438        if cursor < u64::MAX {
439            result.push(Interval::new(cursor, u64::MAX));
440        }
441        result
442    }
443
444    fn reconstruct_path(
445        parent_map: &HashMap<StateKey, Option<StateKey>>,
446        best_g: &HashMap<StateKey, u64>,
447        goal_key: StateKey,
448    ) -> SippPath {
449        let mut path = Vec::new();
450        let mut current = Some(goal_key);
451
452        while let Some(key) = current {
453            let t = best_g[&key];
454            path.push(TimedWaypoint {
455                x: key.x,
456                y: key.y,
457                t,
458            });
459            current = parent_map.get(&key).copied().flatten();
460        }
461
462        path.reverse();
463        path
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    /// Helper to build an empty obstacle map of given size.
472    fn empty_map(width: i32, height: i32) -> Vec<Vec<bool>> {
473        vec![vec![false; height as usize]; width as usize]
474    }
475
476    /// Helper to build a config with no dynamic obstacles and 4-connected grid.
477    fn simple_config(width: i32, height: i32, obstacles: &[(i32, i32)]) -> SippConfig {
478        let mut map = empty_map(width, height);
479        for &(x, y) in obstacles {
480            map[x as usize][y as usize] = true;
481        }
482        SippConfig {
483            width,
484            height,
485            obstacle_map: map,
486            dynamic_obstacles: vec![],
487            allow_diagonal: false,
488        }
489    }
490
491    // -----------------------------------------------------------------------
492    // Basic tests
493    // -----------------------------------------------------------------------
494
495    #[test]
496    fn test_straight_line_no_obstacles() {
497        let config = simple_config(5, 5, &[]);
498        let planner = SippPlanner::new(config).unwrap();
499        let path = planner.plan(0, 0, 4, 0).unwrap();
500
501        assert_eq!(path.len(), 5);
502        assert_eq!(path.first().unwrap(), &TimedWaypoint { x: 0, y: 0, t: 0 });
503        assert_eq!(path.last().unwrap(), &TimedWaypoint { x: 4, y: 0, t: 4 });
504    }
505
506    #[test]
507    fn test_static_obstacle_detour() {
508        // 5x5 grid, wall at x=2 from y=0..3, forcing a detour.
509        let obstacles: Vec<(i32, i32)> = (0..3).map(|y| (2, y)).collect();
510        let config = simple_config(5, 5, &obstacles);
511        let planner = SippPlanner::new(config).unwrap();
512        let path = planner.plan(0, 0, 4, 0).unwrap();
513
514        // Must go around the wall. Path should exist and reach the goal.
515        assert!(path.len() > 5, "path must detour around the wall");
516        assert_eq!(path.first().unwrap().x, 0);
517        assert_eq!(path.first().unwrap().y, 0);
518        assert_eq!(path.last().unwrap().x, 4);
519        assert_eq!(path.last().unwrap().y, 0);
520
521        // No waypoint should be on a static obstacle.
522        for wp in &path {
523            assert!(
524                !obstacles.contains(&(wp.x, wp.y)),
525                "path must not pass through static obstacles"
526            );
527        }
528    }
529
530    #[test]
531    fn test_static_only_behaves_like_astar() {
532        // Without dynamic obstacles, SIPP path length should equal Manhattan
533        // distance on an empty 4-connected grid.
534        let config = simple_config(10, 10, &[]);
535        let planner = SippPlanner::new(config).unwrap();
536        let path = planner.plan(0, 0, 9, 9).unwrap();
537
538        // Manhattan distance = 18, so path has 19 waypoints (including start).
539        assert_eq!(path.len(), 19);
540        assert_eq!(path.last().unwrap().t, 18);
541    }
542
543    // -----------------------------------------------------------------------
544    // Dynamic obstacle tests
545    // -----------------------------------------------------------------------
546
547    #[test]
548    fn test_dynamic_obstacle_blocks_corridor() {
549        // 5x1 corridor: (0,0) -> (4,0).
550        // A dynamic obstacle blocks (2,0) during [2, 5).
551        // Without the obstacle the shortest path arrives at (2,0) at t=2,
552        // which is blocked. The planner must wait at (1,0) until t=4 then
553        // move through (2,0) at t=5.
554        let config = SippConfig {
555            width: 5,
556            height: 1,
557            obstacle_map: empty_map(5, 1),
558            dynamic_obstacles: vec![DynamicObstacle {
559                x: 2,
560                y: 0,
561                interval: Interval::new(2, 5),
562            }],
563            allow_diagonal: false,
564        };
565
566        let planner = SippPlanner::new(config).unwrap();
567        let path = planner.plan(0, 0, 4, 0).unwrap();
568
569        // The robot reaches (2,0) no earlier than t=5.
570        let at_2 = path.iter().find(|wp| wp.x == 2 && wp.y == 0).unwrap();
571        assert!(
572            at_2.t >= 5,
573            "robot should not be at (2,0) before t=5, got t={}",
574            at_2.t
575        );
576
577        assert_eq!(path.last().unwrap().x, 4);
578        assert_eq!(path.last().unwrap().y, 0);
579    }
580
581    #[test]
582    fn test_wait_and_go() {
583        // 3x1 corridor: (0,0) -> (2,0).
584        // Dynamic obstacle at (1,0) during [0, 10).
585        // Robot must wait at (0,0) until t=9 then move to (1,0) at t=10.
586        let config = SippConfig {
587            width: 3,
588            height: 1,
589            obstacle_map: empty_map(3, 1),
590            dynamic_obstacles: vec![DynamicObstacle {
591                x: 1,
592                y: 0,
593                interval: Interval::new(0, 10),
594            }],
595            allow_diagonal: false,
596        };
597
598        let planner = SippPlanner::new(config).unwrap();
599        let path = planner.plan(0, 0, 2, 0).unwrap();
600
601        assert_eq!(path.first().unwrap(), &TimedWaypoint { x: 0, y: 0, t: 0 });
602
603        let at_1 = path.iter().find(|wp| wp.x == 1).unwrap();
604        assert!(
605            at_1.t >= 10,
606            "robot should reach (1,0) at t>=10, got t={}",
607            at_1.t
608        );
609
610        assert_eq!(path.last().unwrap().x, 2);
611        assert_eq!(path.last().unwrap().y, 0);
612        assert!(path.last().unwrap().t >= 11);
613    }
614
615    #[test]
616    fn test_multiple_dynamic_obstacles() {
617        // 7x1 corridor.
618        // Obstacle at (2,0) during [2,5) and at (4,0) during [4,8).
619        let config = SippConfig {
620            width: 7,
621            height: 1,
622            obstacle_map: empty_map(7, 1),
623            dynamic_obstacles: vec![
624                DynamicObstacle {
625                    x: 2,
626                    y: 0,
627                    interval: Interval::new(2, 5),
628                },
629                DynamicObstacle {
630                    x: 4,
631                    y: 0,
632                    interval: Interval::new(4, 8),
633                },
634            ],
635            allow_diagonal: false,
636        };
637
638        let planner = SippPlanner::new(config).unwrap();
639        let path = planner.plan(0, 0, 6, 0).unwrap();
640
641        // Verify no waypoint violates dynamic obstacle constraints.
642        for wp in &path {
643            if wp.x == 2 && wp.y == 0 {
644                assert!(wp.t < 2 || wp.t >= 5, "(2,0) blocked during [2,5)");
645            }
646            if wp.x == 4 && wp.y == 0 {
647                assert!(wp.t < 4 || wp.t >= 8, "(4,0) blocked during [4,8)");
648            }
649        }
650        assert_eq!(path.last().unwrap().x, 6);
651    }
652
653    // -----------------------------------------------------------------------
654    // Safe interval computation tests
655    // -----------------------------------------------------------------------
656
657    #[test]
658    fn test_safe_intervals_no_dynamic() {
659        let config = simple_config(3, 3, &[]);
660        let planner = SippPlanner::new(config).unwrap();
661        let ivs = planner.get_safe_intervals(1, 1);
662        assert_eq!(ivs.len(), 1);
663        assert_eq!(ivs[0], Interval::infinite());
664    }
665
666    #[test]
667    fn test_safe_intervals_with_dynamic() {
668        let config = SippConfig {
669            width: 3,
670            height: 3,
671            obstacle_map: empty_map(3, 3),
672            dynamic_obstacles: vec![DynamicObstacle {
673                x: 1,
674                y: 1,
675                interval: Interval::new(5, 10),
676            }],
677            allow_diagonal: false,
678        };
679        let planner = SippPlanner::new(config).unwrap();
680        let ivs = planner.get_safe_intervals(1, 1);
681        assert_eq!(ivs.len(), 2);
682        assert_eq!(ivs[0], Interval::new(0, 5));
683        assert_eq!(ivs[1], Interval::new(10, u64::MAX));
684    }
685
686    #[test]
687    fn test_safe_intervals_static_obstacle() {
688        let config = simple_config(3, 3, &[(1, 1)]);
689        let planner = SippPlanner::new(config).unwrap();
690        let ivs = planner.get_safe_intervals(1, 1);
691        assert!(ivs.is_empty(), "static obstacle has no safe intervals");
692    }
693
694    // -----------------------------------------------------------------------
695    // Edge cases
696    // -----------------------------------------------------------------------
697
698    #[test]
699    fn test_start_equals_goal() {
700        let config = simple_config(3, 3, &[]);
701        let planner = SippPlanner::new(config).unwrap();
702        let path = planner.plan(1, 1, 1, 1).unwrap();
703        assert_eq!(path.len(), 1);
704        assert_eq!(path[0], TimedWaypoint { x: 1, y: 1, t: 0 });
705    }
706
707    #[test]
708    fn test_no_path_due_to_static_wall() {
709        // Build a wall that completely separates start from goal.
710        let obstacles: Vec<(i32, i32)> = (0..5).map(|y| (2, y)).collect();
711        let config = simple_config(5, 5, &obstacles);
712        let planner = SippPlanner::new(config).unwrap();
713        let result = planner.plan(0, 0, 4, 0);
714        assert!(result.is_err());
715    }
716
717    #[test]
718    fn test_diagonal_movement() {
719        let config = SippConfig {
720            width: 5,
721            height: 5,
722            obstacle_map: empty_map(5, 5),
723            dynamic_obstacles: vec![],
724            allow_diagonal: true,
725        };
726        let planner = SippPlanner::new(config).unwrap();
727        let path = planner.plan(0, 0, 4, 4).unwrap();
728
729        // With diagonal movement, path length should be 5 (Chebyshev distance + 1).
730        assert_eq!(path.len(), 5);
731        assert_eq!(path.last().unwrap().t, 4);
732    }
733
734    #[test]
735    fn test_invalid_start_goal() {
736        let config = simple_config(5, 5, &[]);
737        let planner = SippPlanner::new(config).unwrap();
738
739        assert!(planner.plan(-1, 0, 4, 0).is_err());
740        assert!(planner.plan(0, 0, 5, 0).is_err());
741    }
742}