Skip to main content

rust_robotics_planning/
frontier_navigator.rs

1//! Long Range Navigator-lite: affordance-scored frontier navigation.
2//!
3//! This reproduces the decision core of long-range navigation past the local
4//! map: a robot with a limited, occlusion-aware sensor cannot see a distant
5//! goal, so it repeatedly picks the *frontier* (a known-free cell bordering
6//! unknown space) that best advances toward the goal, then hands off to a local
7//! planner that drives there over the known-free map. As the robot moves it
8//! reveals more of the world, re-evaluates frontiers, and eventually sees a path
9//! to the goal.
10//!
11//! - Sensing is occlusion-aware: cells are revealed only along a clear line of
12//!   sight within the sensor range, so obstacles cast unknown shadows whose
13//!   edges become frontiers.
14//! - Each frontier is scored by an affordance combining goal progress, the
15//!   known-free travel cost to reach it, whether it is in direct line of sight,
16//!   and how much unknown space it borders (information gain).
17//! - The local-planner handoff is a Dijkstra distance field over the known-free
18//!   map: the robot follows its gradient toward the chosen frontier for a
19//!   bounded step budget before re-sensing.
20//!
21//! Everything is deterministic for a fixed world and configuration.
22
23use std::cmp::Ordering;
24use std::collections::{BinaryHeap, VecDeque};
25
26use rust_robotics_core::{RoboticsError, RoboticsResult};
27
28const SQRT2: f64 = std::f64::consts::SQRT_2;
29
30/// Known-map cell state.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Knowledge {
33    Unknown,
34    Free,
35    Occupied,
36}
37
38/// A static occupancy world the robot explores.
39#[derive(Debug, Clone, PartialEq)]
40pub struct FrontierNavWorld {
41    pub width: i32,
42    pub height: i32,
43    /// Ground-truth occupancy indexed `[x][y]`.
44    pub occupied: Vec<Vec<bool>>,
45    pub start: (i32, i32),
46    pub goal: (i32, i32),
47}
48
49impl FrontierNavWorld {
50    /// Empty (all-free) world of the given size.
51    pub fn new(
52        width: i32,
53        height: i32,
54        start: (i32, i32),
55        goal: (i32, i32),
56    ) -> RoboticsResult<Self> {
57        if width <= 0 || height <= 0 {
58            return Err(RoboticsError::InvalidParameter(
59                "frontier-nav world dimensions must be positive".to_string(),
60            ));
61        }
62        Ok(Self {
63            width,
64            height,
65            occupied: vec![vec![false; height as usize]; width as usize],
66            start,
67            goal,
68        })
69    }
70
71    fn in_bounds(&self, x: i32, y: i32) -> bool {
72        x >= 0 && y >= 0 && x < self.width && y < self.height
73    }
74
75    fn is_occupied(&self, x: i32, y: i32) -> bool {
76        !self.in_bounds(x, y) || self.occupied[x as usize][y as usize]
77    }
78
79    /// Set a rectangular block of cells occupied (clamped to bounds).
80    pub fn fill_rect(&mut self, min_x: i32, max_x: i32, min_y: i32, max_y: i32) {
81        for x in min_x.max(0)..=max_x.min(self.width - 1) {
82            for y in min_y.max(0)..=max_y.min(self.height - 1) {
83                self.occupied[x as usize][y as usize] = true;
84            }
85        }
86    }
87}
88
89/// One frontier selection, retained for inspection and rendering.
90#[derive(Debug, Clone, Copy, PartialEq)]
91pub struct FrontierChoice {
92    pub robot: (i32, i32),
93    pub frontier: (i32, i32),
94    pub score: f64,
95    pub line_of_sight: bool,
96}
97
98/// Configuration for the frontier navigator.
99#[derive(Debug, Clone, PartialEq)]
100pub struct FrontierNavConfig {
101    /// Sensor range in cells.
102    pub sensor_range: f64,
103    /// Cells the robot moves toward a frontier before re-sensing.
104    pub step_budget: usize,
105    /// Maximum frontier-selection iterations.
106    pub max_iterations: usize,
107    /// Weight on goal progress from the frontier.
108    pub goal_weight: f64,
109    /// Weight on the known-free travel cost to the frontier.
110    pub cost_weight: f64,
111    /// Bonus for a frontier in direct line of sight.
112    pub line_of_sight_weight: f64,
113    /// Weight on bordered unknown space (information gain).
114    pub openness_weight: f64,
115}
116
117impl Default for FrontierNavConfig {
118    fn default() -> Self {
119        Self {
120            sensor_range: 5.0,
121            step_budget: 3,
122            max_iterations: 200,
123            goal_weight: 1.0,
124            cost_weight: 0.35,
125            line_of_sight_weight: 1.5,
126            openness_weight: 0.15,
127        }
128    }
129}
130
131/// Result of a frontier-navigation rollout.
132#[derive(Debug, Clone, PartialEq)]
133pub struct FrontierNavReport {
134    pub path: Vec<(i32, i32)>,
135    pub reached_goal: bool,
136    pub path_length: f64,
137    pub frontier_selections: usize,
138    pub iterations: usize,
139    pub known_free_cells: usize,
140    pub selected_frontiers: Vec<FrontierChoice>,
141    /// Final known map indexed `[x][y]`.
142    pub known: Vec<Vec<Knowledge>>,
143}
144
145/// Drive the affordance-scored frontier navigator across the world.
146pub fn simulate_frontier_navigation(
147    world: &FrontierNavWorld,
148    config: &FrontierNavConfig,
149) -> RoboticsResult<FrontierNavReport> {
150    validate(world, config)?;
151
152    let (w, h) = (world.width, world.height);
153    let mut known = vec![vec![Knowledge::Unknown; h as usize]; w as usize];
154    let mut robot = world.start;
155    let mut path = vec![robot];
156    let mut path_length = 0.0;
157    let mut selected_frontiers: Vec<FrontierChoice> = Vec::new();
158    let mut reached_goal = false;
159    let mut iterations = 0;
160
161    sense(world, &mut known, robot, config.sensor_range);
162
163    for _ in 0..config.max_iterations {
164        iterations += 1;
165
166        // If the goal is known free and reachable, hand off to the local planner.
167        if known[world.goal.0 as usize][world.goal.1 as usize] == Knowledge::Free {
168            if let Some(dist) = dijkstra_from(&known, world.goal) {
169                if dist[robot.0 as usize][robot.1 as usize].is_finite() {
170                    follow_gradient(
171                        &known,
172                        &dist,
173                        &mut robot,
174                        world.goal,
175                        usize::MAX,
176                        &mut path,
177                        &mut path_length,
178                    );
179                    if robot == world.goal {
180                        reached_goal = true;
181                        break;
182                    }
183                }
184            }
185        }
186
187        let frontiers = compute_frontiers(&known);
188        if frontiers.is_empty() {
189            break;
190        }
191
192        // Travel-cost field from the robot over known-free cells.
193        let reach = match dijkstra_from(&known, robot) {
194            Some(field) => field,
195            None => break,
196        };
197
198        let goal_distance = euclid(robot, world.goal);
199        let max_openness = frontiers
200            .iter()
201            .map(|f| f.openness)
202            .max()
203            .unwrap_or(1)
204            .max(1) as f64;
205
206        let mut best: Option<(f64, &FrontierCluster)> = None;
207        for frontier in &frontiers {
208            let reach_cost = reach[frontier.rep.0 as usize][frontier.rep.1 as usize];
209            if !reach_cost.is_finite() {
210                continue;
211            }
212            let los = line_of_sight(&known, robot, frontier.rep);
213            let goal_gain = goal_distance - euclid(frontier.rep, world.goal);
214            let openness = frontier.openness as f64 / max_openness;
215            let score = config.goal_weight * goal_gain - config.cost_weight * reach_cost
216                + config.line_of_sight_weight * if los { 1.0 } else { 0.0 }
217                + config.openness_weight * openness;
218            let better = match best {
219                None => true,
220                Some((best_score, best_cluster)) => {
221                    score > best_score || (score == best_score && frontier.rep < best_cluster.rep)
222                }
223            };
224            if better {
225                best = Some((score, frontier));
226            }
227        }
228
229        let Some((score, frontier)) = best else { break };
230        let target = frontier.rep;
231        selected_frontiers.push(FrontierChoice {
232            robot,
233            frontier: target,
234            score,
235            line_of_sight: line_of_sight(&known, robot, target),
236        });
237
238        // Local-planner handoff: move toward the frontier over known-free cells.
239        let field = match dijkstra_from(&known, target) {
240            Some(field) => field,
241            None => break,
242        };
243        let before = robot;
244        follow_gradient(
245            &known,
246            &field,
247            &mut robot,
248            target,
249            config.step_budget,
250            &mut path,
251            &mut path_length,
252        );
253        if robot == before {
254            break; // wedged: no progress possible.
255        }
256        sense(world, &mut known, robot, config.sensor_range);
257    }
258
259    let known_free_cells = known
260        .iter()
261        .flatten()
262        .filter(|c| **c == Knowledge::Free)
263        .count();
264
265    Ok(FrontierNavReport {
266        path,
267        reached_goal,
268        path_length,
269        frontier_selections: selected_frontiers.len(),
270        iterations,
271        known_free_cells,
272        selected_frontiers,
273        known,
274    })
275}
276
277fn validate(world: &FrontierNavWorld, config: &FrontierNavConfig) -> RoboticsResult<()> {
278    if !world.in_bounds(world.start.0, world.start.1)
279        || world.is_occupied(world.start.0, world.start.1)
280    {
281        return Err(RoboticsError::InvalidParameter(
282            "frontier-nav start must be a free in-bounds cell".to_string(),
283        ));
284    }
285    if !world.in_bounds(world.goal.0, world.goal.1) || world.is_occupied(world.goal.0, world.goal.1)
286    {
287        return Err(RoboticsError::InvalidParameter(
288            "frontier-nav goal must be a free in-bounds cell".to_string(),
289        ));
290    }
291    if !config.sensor_range.is_finite() || config.sensor_range <= 0.0 {
292        return Err(RoboticsError::InvalidParameter(
293            "frontier-nav sensor range must be finite and positive".to_string(),
294        ));
295    }
296    if config.step_budget == 0 || config.max_iterations == 0 {
297        return Err(RoboticsError::InvalidParameter(
298            "frontier-nav step budget and iterations must be positive".to_string(),
299        ));
300    }
301    Ok(())
302}
303
304/// A cluster of contiguous frontier cells.
305#[derive(Debug, Clone, PartialEq)]
306struct FrontierCluster {
307    rep: (i32, i32),
308    openness: usize,
309}
310
311/// Reveal cells within `range` of `from` along a clear line of sight.
312fn sense(world: &FrontierNavWorld, known: &mut [Vec<Knowledge>], from: (i32, i32), range: f64) {
313    let r = range.ceil() as i32;
314    for dx in -r..=r {
315        for dy in -r..=r {
316            let x = from.0 + dx;
317            let y = from.1 + dy;
318            if !world.in_bounds(x, y) {
319                continue;
320            }
321            if ((dx * dx + dy * dy) as f64).sqrt() > range {
322                continue;
323            }
324            if visible(world, from, (x, y)) {
325                known[x as usize][y as usize] = if world.is_occupied(x, y) {
326                    Knowledge::Occupied
327                } else {
328                    Knowledge::Free
329                };
330            }
331        }
332    }
333}
334
335/// Whether `target` is visible from `from` (no occupied cell strictly between).
336fn visible(world: &FrontierNavWorld, from: (i32, i32), target: (i32, i32)) -> bool {
337    let steps = (((target.0 - from.0).abs()).max((target.1 - from.1).abs()) * 3).max(1);
338    for i in 1..=steps {
339        let t = i as f64 / steps as f64;
340        let x = (from.0 as f64 + t * (target.0 - from.0) as f64).round() as i32;
341        let y = (from.1 as f64 + t * (target.1 - from.1) as f64).round() as i32;
342        if (x, y) == target {
343            return true;
344        }
345        if world.is_occupied(x, y) {
346            return false; // an earlier occupied cell blocks the ray.
347        }
348    }
349    true
350}
351
352/// Line of sight over the *known* map (blocked by known-occupied cells).
353fn line_of_sight(known: &[Vec<Knowledge>], from: (i32, i32), target: (i32, i32)) -> bool {
354    let steps = (((target.0 - from.0).abs()).max((target.1 - from.1).abs()) * 3).max(1);
355    for i in 1..=steps {
356        let t = i as f64 / steps as f64;
357        let x = (from.0 as f64 + t * (target.0 - from.0) as f64).round() as i32;
358        let y = (from.1 as f64 + t * (target.1 - from.1) as f64).round() as i32;
359        if (x, y) == target {
360            return true;
361        }
362        if known[x as usize][y as usize] == Knowledge::Occupied {
363            return false;
364        }
365    }
366    true
367}
368
369/// Frontier cells: known-free cells bordering unknown space, clustered.
370fn compute_frontiers(known: &[Vec<Knowledge>]) -> Vec<FrontierCluster> {
371    let w = known.len() as i32;
372    let h = known[0].len() as i32;
373    let is_free = |x: i32, y: i32| known[x as usize][y as usize] == Knowledge::Free;
374    let unknown_neighbors = |x: i32, y: i32| {
375        let mut count = 0;
376        for (dx, dy) in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
377            let nx = x + dx;
378            let ny = y + dy;
379            if nx >= 0
380                && ny >= 0
381                && nx < w
382                && ny < h
383                && known[nx as usize][ny as usize] == Knowledge::Unknown
384            {
385                count += 1;
386            }
387        }
388        count
389    };
390
391    let mut is_frontier = vec![vec![false; h as usize]; w as usize];
392    for x in 0..w {
393        for y in 0..h {
394            if is_free(x, y) && unknown_neighbors(x, y) > 0 {
395                is_frontier[x as usize][y as usize] = true;
396            }
397        }
398    }
399
400    // Cluster frontier cells with 8-connected BFS.
401    let mut visited = vec![vec![false; h as usize]; w as usize];
402    let mut clusters = Vec::new();
403    for x in 0..w {
404        for y in 0..h {
405            if !is_frontier[x as usize][y as usize] || visited[x as usize][y as usize] {
406                continue;
407            }
408            let mut queue = VecDeque::new();
409            queue.push_back((x, y));
410            visited[x as usize][y as usize] = true;
411            let mut members = Vec::new();
412            let mut openness = 0;
413            while let Some((cx, cy)) = queue.pop_front() {
414                members.push((cx, cy));
415                openness += unknown_neighbors(cx, cy);
416                for dx in -1..=1 {
417                    for dy in -1..=1 {
418                        if dx == 0 && dy == 0 {
419                            continue;
420                        }
421                        let nx = cx + dx;
422                        let ny = cy + dy;
423                        if nx >= 0
424                            && ny >= 0
425                            && nx < w
426                            && ny < h
427                            && is_frontier[nx as usize][ny as usize]
428                            && !visited[nx as usize][ny as usize]
429                        {
430                            visited[nx as usize][ny as usize] = true;
431                            queue.push_back((nx, ny));
432                        }
433                    }
434                }
435            }
436            // Representative: member closest to the cluster centroid.
437            let cx = members.iter().map(|m| m.0).sum::<i32>() as f64 / members.len() as f64;
438            let cy = members.iter().map(|m| m.1).sum::<i32>() as f64 / members.len() as f64;
439            let rep = *members
440                .iter()
441                .min_by(|a, b| {
442                    let da = (a.0 as f64 - cx).powi(2) + (a.1 as f64 - cy).powi(2);
443                    let db = (b.0 as f64 - cx).powi(2) + (b.1 as f64 - cy).powi(2);
444                    da.partial_cmp(&db)
445                        .unwrap_or(Ordering::Equal)
446                        .then_with(|| a.cmp(b))
447                })
448                .unwrap();
449            clusters.push(FrontierCluster { rep, openness });
450        }
451    }
452    clusters
453}
454
455/// Dijkstra distance field from `source` over known-free cells (8-connected).
456fn dijkstra_from(known: &[Vec<Knowledge>], source: (i32, i32)) -> Option<Vec<Vec<f64>>> {
457    let w = known.len() as i32;
458    let h = known[0].len() as i32;
459    if known[source.0 as usize][source.1 as usize] != Knowledge::Free {
460        return None;
461    }
462    let mut dist = vec![vec![f64::INFINITY; h as usize]; w as usize];
463    dist[source.0 as usize][source.1 as usize] = 0.0;
464    let mut heap = BinaryHeap::new();
465    heap.push(DijkstraNode {
466        cost: 0.0,
467        cell: source,
468    });
469    while let Some(DijkstraNode { cost, cell }) = heap.pop() {
470        if cost > dist[cell.0 as usize][cell.1 as usize] {
471            continue;
472        }
473        for dx in -1..=1 {
474            for dy in -1..=1 {
475                if dx == 0 && dy == 0 {
476                    continue;
477                }
478                let nx = cell.0 + dx;
479                let ny = cell.1 + dy;
480                if nx < 0 || ny < 0 || nx >= w || ny >= h {
481                    continue;
482                }
483                if known[nx as usize][ny as usize] != Knowledge::Free {
484                    continue;
485                }
486                let step = if dx != 0 && dy != 0 { SQRT2 } else { 1.0 };
487                let next = cost + step;
488                if next < dist[nx as usize][ny as usize] {
489                    dist[nx as usize][ny as usize] = next;
490                    heap.push(DijkstraNode {
491                        cost: next,
492                        cell: (nx, ny),
493                    });
494                }
495            }
496        }
497    }
498    Some(dist)
499}
500
501/// Step the robot down a distance field toward `target` for up to `budget` cells.
502fn follow_gradient(
503    known: &[Vec<Knowledge>],
504    field: &[Vec<f64>],
505    robot: &mut (i32, i32),
506    target: (i32, i32),
507    budget: usize,
508    path: &mut Vec<(i32, i32)>,
509    path_length: &mut f64,
510) {
511    let w = known.len() as i32;
512    let h = known[0].len() as i32;
513    for _ in 0..budget {
514        if *robot == target {
515            break;
516        }
517        let here = field[robot.0 as usize][robot.1 as usize];
518        let mut best: Option<(f64, (i32, i32), f64)> = None;
519        for dx in -1..=1 {
520            for dy in -1..=1 {
521                if dx == 0 && dy == 0 {
522                    continue;
523                }
524                let nx = robot.0 + dx;
525                let ny = robot.1 + dy;
526                if nx < 0 || ny < 0 || nx >= w || ny >= h {
527                    continue;
528                }
529                if known[nx as usize][ny as usize] != Knowledge::Free {
530                    continue;
531                }
532                let d = field[nx as usize][ny as usize];
533                if !d.is_finite() || d >= here {
534                    continue;
535                }
536                let step = if dx != 0 && dy != 0 { SQRT2 } else { 1.0 };
537                let better = match best {
538                    None => true,
539                    Some((best_d, best_cell, _)) => {
540                        d < best_d || (d == best_d && (nx, ny) < best_cell)
541                    }
542                };
543                if better {
544                    best = Some((d, (nx, ny), step));
545                }
546            }
547        }
548        let Some((_, next, step)) = best else { break };
549        *robot = next;
550        path.push(next);
551        *path_length += step;
552    }
553}
554
555#[derive(Debug, Clone, Copy)]
556struct DijkstraNode {
557    cost: f64,
558    cell: (i32, i32),
559}
560
561impl PartialEq for DijkstraNode {
562    fn eq(&self, other: &Self) -> bool {
563        self.cost == other.cost
564    }
565}
566impl Eq for DijkstraNode {}
567impl PartialOrd for DijkstraNode {
568    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
569        Some(self.cmp(other))
570    }
571}
572impl Ord for DijkstraNode {
573    fn cmp(&self, other: &Self) -> Ordering {
574        // Min-heap on cost; tie-break by cell for determinism.
575        other
576            .cost
577            .partial_cmp(&self.cost)
578            .unwrap_or(Ordering::Equal)
579            .then_with(|| other.cell.cmp(&self.cell))
580    }
581}
582
583fn euclid(a: (i32, i32), b: (i32, i32)) -> f64 {
584    let dx = (a.0 - b.0) as f64;
585    let dy = (a.1 - b.1) as f64;
586    (dx * dx + dy * dy).sqrt()
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592
593    /// A world with a wall between start and goal, leaving a gap at the top.
594    fn walled_world() -> FrontierNavWorld {
595        let mut world = FrontierNavWorld::new(24, 16, (1, 8), (22, 8)).unwrap();
596        // Vertical wall at x = 12 from y = 0 up to y = 12 (gap above y = 12).
597        world.fill_rect(12, 13, 0, 12);
598        world
599    }
600
601    #[test]
602    fn reaches_goal_around_occlusion() {
603        let report =
604            simulate_frontier_navigation(&walled_world(), &FrontierNavConfig::default()).unwrap();
605        assert!(report.reached_goal, "did not reach goal");
606        assert!(*report.path.last().unwrap() == (22, 8));
607        // Had to choose at least one frontier to get around the wall.
608        assert!(report.frontier_selections >= 1);
609    }
610
611    #[test]
612    fn open_world_reaches_goal() {
613        let world = FrontierNavWorld::new(20, 12, (1, 6), (18, 6)).unwrap();
614        let report = simulate_frontier_navigation(&world, &FrontierNavConfig::default()).unwrap();
615        assert!(report.reached_goal);
616    }
617
618    #[test]
619    fn is_deterministic() {
620        let world = walled_world();
621        let config = FrontierNavConfig::default();
622        let first = simulate_frontier_navigation(&world, &config).unwrap();
623        let second = simulate_frontier_navigation(&world, &config).unwrap();
624        assert_eq!(first.path, second.path);
625        assert_eq!(first.frontier_selections, second.frontier_selections);
626    }
627
628    #[test]
629    fn sensing_is_occlusion_aware() {
630        // A short wall at x = 4 within sensor range of the robot at (1, 8).
631        let mut world = FrontierNavWorld::new(24, 16, (1, 8), (22, 8)).unwrap();
632        world.fill_rect(4, 4, 5, 11);
633        let mut known = vec![vec![Knowledge::Unknown; 16]; 24];
634        sense(&world, &mut known, (1, 8), 6.0);
635        // A near free cell is revealed.
636        assert_eq!(known[3][8], Knowledge::Free);
637        // The near wall face is seen as occupied.
638        assert_eq!(known[4][8], Knowledge::Occupied);
639        // A cell just behind the wall (within range) stays unknown (occluded).
640        assert_eq!(known[6][8], Knowledge::Unknown);
641    }
642
643    #[test]
644    fn rejects_blocked_start() {
645        let mut world = FrontierNavWorld::new(10, 10, (1, 1), (8, 8)).unwrap();
646        world.fill_rect(1, 1, 1, 1);
647        assert!(simulate_frontier_navigation(&world, &FrontierNavConfig::default()).is_err());
648    }
649}