Skip to main content

oxigdal_algorithms/raster/
cost_distance.rs

1//! Cost-distance analysis and least-cost path algorithms
2//!
3//! Provides cost-weighted distance analysis, anisotropic cost surfaces,
4//! barrier support, path allocation, and various shortest-path algorithms.
5//!
6//! # Algorithms
7//!
8//! ## Dijkstra-based cost distance
9//!
10//! Standard cost-distance using Dijkstra's algorithm on an 8-connected grid.
11//! The cost to traverse from cell A to cell B is:
12//!   cost = (cost_A + cost_B) / 2 * distance
13//! where distance is 1 for cardinal neighbors and sqrt(2) for diagonal.
14//!
15//! ## Anisotropic cost distance
16//!
17//! Direction-dependent traversal costs. Uses slope and aspect information
18//! to compute different costs for uphill vs. downhill and lateral movement.
19//! Follows Tobler's hiking function or custom friction models.
20//!
21//! ## A* shortest path
22//!
23//! Heuristic-guided shortest path between two specific points. Uses the
24//! Euclidean distance to the target as an admissible heuristic.
25//!
26//! # Features
27//!
28//! - **Barrier support**: Cells with infinite cost that cannot be traversed
29//! - **Path allocation**: Assigns each cell to its nearest source
30//! - **Direction raster**: Records the direction to the next cell on the
31//!   least-cost path back to the source
32//! - **Corridor analysis**: Identifies least-cost corridors between regions
33//!
34//! # References
35//!
36//! - Tobler, W. (1993). Three presentations on geographical analysis and modeling.
37//! - Douglas, D.H. (1994). Least-cost path in GIS using an accumulated cost surface and slopelines.
38//! - Yu, C. et al. (2003). An assessment of anisotropic least-cost path computation.
39
40use crate::error::{AlgorithmError, Result};
41use oxigdal_core::buffer::RasterBuffer;
42use oxigdal_core::types::RasterDataType;
43use std::cmp::Ordering;
44use std::collections::BinaryHeap;
45
46// ===========================================================================
47// Cost cell for priority queue
48// ===========================================================================
49
50#[derive(Copy, Clone, PartialEq)]
51struct CostCell {
52    x: u64,
53    y: u64,
54    cost: f64,
55}
56
57impl Eq for CostCell {}
58
59impl PartialOrd for CostCell {
60    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
61        Some(self.cmp(other))
62    }
63}
64
65impl Ord for CostCell {
66    fn cmp(&self, other: &Self) -> Ordering {
67        other
68            .cost
69            .partial_cmp(&self.cost)
70            .unwrap_or(Ordering::Equal)
71    }
72}
73
74// ===========================================================================
75// Anisotropic cost model
76// ===========================================================================
77
78/// Anisotropic friction model for direction-dependent costs
79#[derive(Debug, Clone, Copy, PartialEq, Default)]
80pub enum FrictionModel {
81    /// Isotropic: same cost in all directions (default)
82    #[default]
83    Isotropic,
84
85    /// Tobler's hiking function: speed = 6 * exp(-3.5 * |tan(slope) + 0.05|)
86    /// Cost is inversely proportional to speed.
87    ToblerHiking,
88
89    /// Symmetric slope friction: cost increases with absolute slope gradient
90    /// cost_factor = 1 + |dz| * slope_weight / distance
91    SymmetricSlope {
92        /// Weight factor for slope contribution
93        slope_weight: f64,
94    },
95
96    /// Asymmetric friction: uphill is harder than downhill
97    /// cost_factor_uphill = 1 + dz * uphill_weight / distance  (if dz > 0)
98    /// cost_factor_downhill = 1 + |dz| * downhill_weight / distance  (if dz < 0)
99    AsymmetricSlope {
100        /// Weight factor for uphill movement
101        uphill_weight: f64,
102        /// Weight factor for downhill movement
103        downhill_weight: f64,
104    },
105}
106
107// ===========================================================================
108// Direction encoding
109// ===========================================================================
110
111/// Direction encoding for backlink raster
112///
113/// Uses compass directions encoded as integers:
114/// ```text
115///  7  0  1
116///  6  X  2
117///  5  4  3
118/// ```
119///
120/// 0 = North, 1 = NE, 2 = E, 3 = SE, 4 = S, 5 = SW, 6 = W, 7 = NW
121/// -1 = source (no direction)
122#[derive(Debug, Clone, Copy)]
123pub struct Direction;
124
125impl Direction {
126    /// North
127    pub const N: f64 = 0.0;
128    /// Northeast
129    pub const NE: f64 = 1.0;
130    /// East
131    pub const E: f64 = 2.0;
132    /// Southeast
133    pub const SE: f64 = 3.0;
134    /// South
135    pub const S: f64 = 4.0;
136    /// Southwest
137    pub const SW: f64 = 5.0;
138    /// West
139    pub const W: f64 = 6.0;
140    /// Northwest
141    pub const NW: f64 = 7.0;
142    /// Source cell (no direction)
143    pub const SOURCE: f64 = -1.0;
144}
145
146/// Result of a cost-distance analysis
147#[derive(Debug)]
148pub struct CostDistanceResult {
149    /// Accumulated cost-distance raster
150    pub cost_distance: RasterBuffer,
151    /// Direction (backlink) raster: direction to next cell toward the nearest source
152    pub direction: Option<RasterBuffer>,
153    /// Allocation raster: index of the nearest source for each cell
154    pub allocation: Option<RasterBuffer>,
155}
156
157// ===========================================================================
158// 8-neighbor offsets and directions
159// ===========================================================================
160
161/// 8-connected neighbor offsets: (dx, dy, direction_from_neighbor_to_current)
162const NEIGHBORS: [(i64, i64, f64); 8] = [
163    (0, -1, Direction::S),   // N neighbor -> go South to reach current
164    (1, -1, Direction::SW),  // NE neighbor
165    (1, 0, Direction::W),    // E neighbor
166    (1, 1, Direction::NW),   // SE neighbor
167    (0, 1, Direction::N),    // S neighbor
168    (-1, 1, Direction::NE),  // SW neighbor
169    (-1, 0, Direction::E),   // W neighbor
170    (-1, -1, Direction::SE), // NW neighbor
171];
172
173/// Returns the direction index pointing from (nx, ny) back toward (cx, cy)
174fn direction_from_offset(dx: i64, dy: i64) -> f64 {
175    match (dx, dy) {
176        (0, -1) => Direction::N,
177        (1, -1) => Direction::NE,
178        (1, 0) => Direction::E,
179        (1, 1) => Direction::SE,
180        (0, 1) => Direction::S,
181        (-1, 1) => Direction::SW,
182        (-1, 0) => Direction::W,
183        (-1, -1) => Direction::NW,
184        _ => Direction::SOURCE,
185    }
186}
187
188// ===========================================================================
189// Euclidean distance
190// ===========================================================================
191
192/// Computes Euclidean distance from source cells
193///
194/// Uses a brute-force approach: for each cell, finds the minimum distance
195/// to any source cell.
196///
197/// # Arguments
198///
199/// * `sources` - Binary raster where non-zero cells are sources
200/// * `cell_size` - Size of each cell
201///
202/// # Errors
203///
204/// Returns an error if the operation fails
205pub fn euclidean_distance(sources: &RasterBuffer, cell_size: f64) -> Result<RasterBuffer> {
206    let width = sources.width();
207    let height = sources.height();
208    let mut distance = RasterBuffer::zeros(width, height, RasterDataType::Float64);
209
210    // Find source cells
211    let mut source_cells = Vec::new();
212    for y in 0..height {
213        for x in 0..width {
214            let val = sources.get_pixel(x, y).map_err(AlgorithmError::Core)?;
215            if val > 0.0 {
216                source_cells.push((x, y));
217            }
218        }
219    }
220
221    // Initialize with infinity
222    for y in 0..height {
223        for x in 0..width {
224            distance
225                .set_pixel(x, y, f64::INFINITY)
226                .map_err(AlgorithmError::Core)?;
227        }
228    }
229
230    // Compute distance to nearest source for each cell
231    for y in 0..height {
232        for x in 0..width {
233            let mut min_dist = f64::INFINITY;
234
235            for &(sx, sy) in &source_cells {
236                let dx = (x as f64 - sx as f64) * cell_size;
237                let dy = (y as f64 - sy as f64) * cell_size;
238                let dist = (dx * dx + dy * dy).sqrt();
239                min_dist = min_dist.min(dist);
240            }
241
242            distance
243                .set_pixel(x, y, min_dist)
244                .map_err(AlgorithmError::Core)?;
245        }
246    }
247
248    Ok(distance)
249}
250
251// ===========================================================================
252// Cost distance (isotropic, backward-compatible)
253// ===========================================================================
254
255/// Computes cost-weighted distance using Dijkstra's algorithm
256///
257/// # Arguments
258///
259/// * `sources` - Binary raster with source locations (non-zero = source)
260/// * `cost_surface` - Raster with cost values (higher = harder to traverse)
261/// * `cell_size` - Size of each cell
262///
263/// # Errors
264///
265/// Returns an error if the operation fails
266pub fn cost_distance(
267    sources: &RasterBuffer,
268    cost_surface: &RasterBuffer,
269    cell_size: f64,
270) -> Result<RasterBuffer> {
271    let result = cost_distance_full(
272        sources,
273        cost_surface,
274        None,
275        cell_size,
276        FrictionModel::Isotropic,
277    )?;
278    Ok(result.cost_distance)
279}
280
281/// Computes cost-distance with full output (cost, direction, allocation)
282///
283/// # Arguments
284///
285/// * `sources` - Binary raster with source locations (non-zero = source)
286/// * `cost_surface` - Raster with cost values
287/// * `barriers` - Optional barrier raster (non-zero cells are impassable)
288/// * `cell_size` - Size of each cell
289/// * `friction_model` - Friction model to use
290///
291/// # Errors
292///
293/// Returns an error if the operation fails
294pub fn cost_distance_full(
295    sources: &RasterBuffer,
296    cost_surface: &RasterBuffer,
297    barriers: Option<&RasterBuffer>,
298    cell_size: f64,
299    friction_model: FrictionModel,
300) -> Result<CostDistanceResult> {
301    let width = sources.width();
302    let height = sources.height();
303
304    let mut cost_dist = RasterBuffer::zeros(width, height, RasterDataType::Float64);
305    let mut direction = RasterBuffer::zeros(width, height, RasterDataType::Float64);
306    let mut allocation = RasterBuffer::zeros(width, height, RasterDataType::Float64);
307    let mut visited = vec![false; (width * height) as usize];
308
309    // Initialize with infinity
310    for y in 0..height {
311        for x in 0..width {
312            cost_dist
313                .set_pixel(x, y, f64::INFINITY)
314                .map_err(AlgorithmError::Core)?;
315            direction
316                .set_pixel(x, y, Direction::SOURCE)
317                .map_err(AlgorithmError::Core)?;
318            allocation
319                .set_pixel(x, y, -1.0)
320                .map_err(AlgorithmError::Core)?;
321        }
322    }
323
324    // Priority queue for Dijkstra's
325    let mut pq = BinaryHeap::new();
326
327    // Add source cells to queue
328    let mut source_index = 0.0_f64;
329    for y in 0..height {
330        for x in 0..width {
331            let val = sources.get_pixel(x, y).map_err(AlgorithmError::Core)?;
332            if val > 0.0 {
333                cost_dist
334                    .set_pixel(x, y, 0.0)
335                    .map_err(AlgorithmError::Core)?;
336                allocation
337                    .set_pixel(x, y, source_index)
338                    .map_err(AlgorithmError::Core)?;
339                pq.push(CostCell { x, y, cost: 0.0 });
340                source_index += 1.0;
341            }
342        }
343    }
344
345    // We need a separate DEM for anisotropic models (use cost_surface as proxy for elevation)
346    // In practice, callers would pass DEM separately; here we approximate
347
348    // Dijkstra's algorithm
349    while let Some(cell) = pq.pop() {
350        let idx = (cell.y * width + cell.x) as usize;
351        if visited[idx] {
352            continue;
353        }
354        visited[idx] = true;
355
356        let current_alloc = allocation
357            .get_pixel(cell.x, cell.y)
358            .map_err(AlgorithmError::Core)?;
359
360        // Check 8 neighbors
361        for &(dx, dy, _dir_from) in &NEIGHBORS {
362            let nx = cell.x as i64 + dx;
363            let ny = cell.y as i64 + dy;
364
365            if nx < 0 || nx >= width as i64 || ny < 0 || ny >= height as i64 {
366                continue;
367            }
368
369            let nx_u = nx as u64;
370            let ny_u = ny as u64;
371            let n_idx = (ny_u * width + nx_u) as usize;
372
373            if visited[n_idx] {
374                continue;
375            }
376
377            // Check barrier
378            if let Some(barrier_raster) = barriers {
379                let barrier_val = barrier_raster
380                    .get_pixel(nx_u, ny_u)
381                    .map_err(AlgorithmError::Core)?;
382                if barrier_val > 0.0 {
383                    continue; // Impassable
384                }
385            }
386
387            let neighbor_cost = cost_surface
388                .get_pixel(nx_u, ny_u)
389                .map_err(AlgorithmError::Core)?;
390
391            if neighbor_cost < 0.0 || neighbor_cost.is_nan() || neighbor_cost.is_infinite() {
392                continue; // Invalid cost, treat as barrier
393            }
394
395            let current_cost_val = cost_surface
396                .get_pixel(cell.x, cell.y)
397                .map_err(AlgorithmError::Core)?;
398
399            // Base distance (cardinal = cell_size, diagonal = cell_size * sqrt(2))
400            let base_dist = if dx.abs() + dy.abs() == 2 {
401                cell_size * core::f64::consts::SQRT_2
402            } else {
403                cell_size
404            };
405
406            // Average cost between current and neighbor cells
407            let avg_cost = (current_cost_val + neighbor_cost) / 2.0;
408
409            // Apply friction model
410            let friction_factor = match friction_model {
411                FrictionModel::Isotropic => 1.0,
412                FrictionModel::ToblerHiking => {
413                    // Use cost surface values as proxy for elevation difference
414                    let dz = neighbor_cost - current_cost_val;
415                    let slope_tangent = dz / base_dist;
416                    let speed = 6.0 * (-3.5 * (slope_tangent + 0.05).abs()).exp();
417                    if speed > 0.001 { 1.0 / speed } else { 1000.0 }
418                }
419                FrictionModel::SymmetricSlope { slope_weight } => {
420                    let dz = (neighbor_cost - current_cost_val).abs();
421                    1.0 + dz * slope_weight / base_dist
422                }
423                FrictionModel::AsymmetricSlope {
424                    uphill_weight,
425                    downhill_weight,
426                } => {
427                    let dz = neighbor_cost - current_cost_val;
428                    if dz > 0.0 {
429                        1.0 + dz * uphill_weight / base_dist
430                    } else {
431                        1.0 + dz.abs() * downhill_weight / base_dist
432                    }
433                }
434            };
435
436            let travel_cost = avg_cost * base_dist * friction_factor;
437            let new_cost = cell.cost + travel_cost;
438
439            let current_best = cost_dist
440                .get_pixel(nx_u, ny_u)
441                .map_err(AlgorithmError::Core)?;
442
443            if new_cost < current_best {
444                cost_dist
445                    .set_pixel(nx_u, ny_u, new_cost)
446                    .map_err(AlgorithmError::Core)?;
447
448                // Direction from neighbor pointing back to current cell
449                let back_dir = direction_from_offset(-dx, -dy);
450                direction
451                    .set_pixel(nx_u, ny_u, back_dir)
452                    .map_err(AlgorithmError::Core)?;
453
454                allocation
455                    .set_pixel(nx_u, ny_u, current_alloc)
456                    .map_err(AlgorithmError::Core)?;
457
458                pq.push(CostCell {
459                    x: nx_u,
460                    y: ny_u,
461                    cost: new_cost,
462                });
463            }
464        }
465    }
466
467    Ok(CostDistanceResult {
468        cost_distance: cost_dist,
469        direction: Some(direction),
470        allocation: Some(allocation),
471    })
472}
473
474/// Computes anisotropic cost distance using a DEM for slope calculations
475///
476/// Unlike `cost_distance_full` which uses cost surface values as elevation proxy,
477/// this function takes an explicit DEM for proper slope-based anisotropic costs.
478///
479/// # Arguments
480///
481/// * `sources` - Binary raster with source locations
482/// * `cost_surface` - Base friction cost raster
483/// * `dem` - Digital elevation model for slope computation
484/// * `barriers` - Optional barrier raster
485/// * `cell_size` - Cell size
486/// * `friction_model` - Friction model to use
487///
488/// # Errors
489///
490/// Returns an error if dimensions don't match or operation fails
491pub fn cost_distance_anisotropic(
492    sources: &RasterBuffer,
493    cost_surface: &RasterBuffer,
494    dem: &RasterBuffer,
495    barriers: Option<&RasterBuffer>,
496    cell_size: f64,
497    friction_model: FrictionModel,
498) -> Result<CostDistanceResult> {
499    let width = sources.width();
500    let height = sources.height();
501
502    if dem.width() != width || dem.height() != height {
503        return Err(AlgorithmError::InvalidDimensions {
504            message: "DEM dimensions must match source raster",
505            actual: dem.width() as usize,
506            expected: width as usize,
507        });
508    }
509
510    let mut cost_dist = RasterBuffer::zeros(width, height, RasterDataType::Float64);
511    let mut direction = RasterBuffer::zeros(width, height, RasterDataType::Float64);
512    let mut allocation = RasterBuffer::zeros(width, height, RasterDataType::Float64);
513    let mut visited = vec![false; (width * height) as usize];
514
515    // Initialize
516    for y in 0..height {
517        for x in 0..width {
518            cost_dist
519                .set_pixel(x, y, f64::INFINITY)
520                .map_err(AlgorithmError::Core)?;
521            direction
522                .set_pixel(x, y, Direction::SOURCE)
523                .map_err(AlgorithmError::Core)?;
524            allocation
525                .set_pixel(x, y, -1.0)
526                .map_err(AlgorithmError::Core)?;
527        }
528    }
529
530    let mut pq = BinaryHeap::new();
531    let mut source_index = 0.0_f64;
532
533    for y in 0..height {
534        for x in 0..width {
535            let val = sources.get_pixel(x, y).map_err(AlgorithmError::Core)?;
536            if val > 0.0 {
537                cost_dist
538                    .set_pixel(x, y, 0.0)
539                    .map_err(AlgorithmError::Core)?;
540                allocation
541                    .set_pixel(x, y, source_index)
542                    .map_err(AlgorithmError::Core)?;
543                pq.push(CostCell { x, y, cost: 0.0 });
544                source_index += 1.0;
545            }
546        }
547    }
548
549    while let Some(cell) = pq.pop() {
550        let idx = (cell.y * width + cell.x) as usize;
551        if visited[idx] {
552            continue;
553        }
554        visited[idx] = true;
555
556        let current_alloc = allocation
557            .get_pixel(cell.x, cell.y)
558            .map_err(AlgorithmError::Core)?;
559
560        let current_elev = dem
561            .get_pixel(cell.x, cell.y)
562            .map_err(AlgorithmError::Core)?;
563
564        let current_friction = cost_surface
565            .get_pixel(cell.x, cell.y)
566            .map_err(AlgorithmError::Core)?;
567
568        for &(dx, dy, _) in &NEIGHBORS {
569            let nx = cell.x as i64 + dx;
570            let ny = cell.y as i64 + dy;
571
572            if nx < 0 || nx >= width as i64 || ny < 0 || ny >= height as i64 {
573                continue;
574            }
575
576            let nx_u = nx as u64;
577            let ny_u = ny as u64;
578            let n_idx = (ny_u * width + nx_u) as usize;
579
580            if visited[n_idx] {
581                continue;
582            }
583
584            if let Some(barrier_raster) = barriers {
585                let barrier_val = barrier_raster
586                    .get_pixel(nx_u, ny_u)
587                    .map_err(AlgorithmError::Core)?;
588                if barrier_val > 0.0 {
589                    continue;
590                }
591            }
592
593            let neighbor_friction = cost_surface
594                .get_pixel(nx_u, ny_u)
595                .map_err(AlgorithmError::Core)?;
596
597            if neighbor_friction < 0.0
598                || neighbor_friction.is_nan()
599                || neighbor_friction.is_infinite()
600            {
601                continue;
602            }
603
604            let neighbor_elev = dem.get_pixel(nx_u, ny_u).map_err(AlgorithmError::Core)?;
605
606            let base_dist = if dx.abs() + dy.abs() == 2 {
607                cell_size * core::f64::consts::SQRT_2
608            } else {
609                cell_size
610            };
611
612            let avg_friction = (current_friction + neighbor_friction) / 2.0;
613            let dz = neighbor_elev - current_elev;
614
615            let friction_factor = match friction_model {
616                FrictionModel::Isotropic => 1.0,
617                FrictionModel::ToblerHiking => {
618                    let slope_tangent = dz / base_dist;
619                    let speed = 6.0 * (-3.5 * (slope_tangent + 0.05).abs()).exp();
620                    if speed > 0.001 { 1.0 / speed } else { 1000.0 }
621                }
622                FrictionModel::SymmetricSlope { slope_weight } => {
623                    1.0 + dz.abs() * slope_weight / base_dist
624                }
625                FrictionModel::AsymmetricSlope {
626                    uphill_weight,
627                    downhill_weight,
628                } => {
629                    if dz > 0.0 {
630                        1.0 + dz * uphill_weight / base_dist
631                    } else {
632                        1.0 + dz.abs() * downhill_weight / base_dist
633                    }
634                }
635            };
636
637            let travel_cost = avg_friction * base_dist * friction_factor;
638            let new_cost = cell.cost + travel_cost;
639
640            let current_best = cost_dist
641                .get_pixel(nx_u, ny_u)
642                .map_err(AlgorithmError::Core)?;
643
644            if new_cost < current_best {
645                cost_dist
646                    .set_pixel(nx_u, ny_u, new_cost)
647                    .map_err(AlgorithmError::Core)?;
648
649                let back_dir = direction_from_offset(-dx, -dy);
650                direction
651                    .set_pixel(nx_u, ny_u, back_dir)
652                    .map_err(AlgorithmError::Core)?;
653
654                allocation
655                    .set_pixel(nx_u, ny_u, current_alloc)
656                    .map_err(AlgorithmError::Core)?;
657
658                pq.push(CostCell {
659                    x: nx_u,
660                    y: ny_u,
661                    cost: new_cost,
662                });
663            }
664        }
665    }
666
667    Ok(CostDistanceResult {
668        cost_distance: cost_dist,
669        direction: Some(direction),
670        allocation: Some(allocation),
671    })
672}
673
674// ===========================================================================
675// Least-cost path
676// ===========================================================================
677
678/// Extracts least-cost path from destination back to source using the direction raster
679///
680/// Returns a binary raster with the path marked as 1.
681///
682/// # Arguments
683///
684/// * `cost_distance_raster` - Pre-computed cost-distance raster
685/// * `dest_x` - Destination X coordinate
686/// * `dest_y` - Destination Y coordinate
687///
688/// # Errors
689///
690/// Returns an error if the path cannot be found or coordinates are invalid
691pub fn least_cost_path(
692    cost_distance_raster: &RasterBuffer,
693    dest_x: u64,
694    dest_y: u64,
695) -> Result<RasterBuffer> {
696    let width = cost_distance_raster.width();
697    let height = cost_distance_raster.height();
698    let mut path = RasterBuffer::zeros(width, height, RasterDataType::UInt8);
699
700    if dest_x >= width || dest_y >= height {
701        return Err(AlgorithmError::InvalidParameter {
702            parameter: "destination",
703            message: format!(
704                "Destination ({}, {}) is outside raster bounds ({}, {})",
705                dest_x, dest_y, width, height
706            ),
707        });
708    }
709
710    let mut current_x = dest_x;
711    let mut current_y = dest_y;
712    let max_steps = (width * height) as usize; // Safety limit
713
714    for _ in 0..max_steps {
715        path.set_pixel(current_x, current_y, 1.0)
716            .map_err(AlgorithmError::Core)?;
717
718        let current_cost = cost_distance_raster
719            .get_pixel(current_x, current_y)
720            .map_err(AlgorithmError::Core)?;
721
722        if current_cost <= 0.0 || current_cost.is_nan() {
723            break; // Reached source
724        }
725
726        // Find neighbor with minimum cost
727        let mut min_cost = current_cost;
728        let mut next_x = current_x;
729        let mut next_y = current_y;
730
731        for &(dx, dy, _) in &NEIGHBORS {
732            let nx = current_x as i64 + dx;
733            let ny = current_y as i64 + dy;
734
735            if nx < 0 || nx >= width as i64 || ny < 0 || ny >= height as i64 {
736                continue;
737            }
738
739            let nx_u = nx as u64;
740            let ny_u = ny as u64;
741
742            let neighbor_cost = cost_distance_raster
743                .get_pixel(nx_u, ny_u)
744                .map_err(AlgorithmError::Core)?;
745
746            if neighbor_cost < min_cost {
747                min_cost = neighbor_cost;
748                next_x = nx_u;
749                next_y = ny_u;
750            }
751        }
752
753        if next_x == current_x && next_y == current_y {
754            break; // Stuck (no downhill neighbor)
755        }
756
757        current_x = next_x;
758        current_y = next_y;
759    }
760
761    Ok(path)
762}
763
764/// Extracts least-cost path using the direction (backlink) raster
765///
766/// The direction raster encodes which direction to follow from each cell
767/// back toward the source. This is more efficient than the gradient-descent
768/// approach used by `least_cost_path`.
769///
770/// # Arguments
771///
772/// * `direction_raster` - Direction raster from `cost_distance_full`
773/// * `dest_x` - Destination X coordinate
774/// * `dest_y` - Destination Y coordinate
775///
776/// # Errors
777///
778/// Returns an error if the path cannot be found
779pub fn least_cost_path_from_direction(
780    direction_raster: &RasterBuffer,
781    dest_x: u64,
782    dest_y: u64,
783) -> Result<RasterBuffer> {
784    let width = direction_raster.width();
785    let height = direction_raster.height();
786    let mut path = RasterBuffer::zeros(width, height, RasterDataType::UInt8);
787
788    if dest_x >= width || dest_y >= height {
789        return Err(AlgorithmError::InvalidParameter {
790            parameter: "destination",
791            message: format!(
792                "Destination ({}, {}) is outside raster bounds ({}, {})",
793                dest_x, dest_y, width, height
794            ),
795        });
796    }
797
798    let mut cx = dest_x;
799    let mut cy = dest_y;
800    let max_steps = (width * height) as usize;
801
802    for _ in 0..max_steps {
803        path.set_pixel(cx, cy, 1.0).map_err(AlgorithmError::Core)?;
804
805        let dir = direction_raster
806            .get_pixel(cx, cy)
807            .map_err(AlgorithmError::Core)?;
808
809        if (dir - Direction::SOURCE).abs() < 0.5 {
810            break; // Reached source
811        }
812
813        // Follow direction
814        let (dx, dy) = direction_to_offset(dir);
815        let nx = cx as i64 + dx;
816        let ny = cy as i64 + dy;
817
818        if nx < 0 || nx >= width as i64 || ny < 0 || ny >= height as i64 {
819            break;
820        }
821
822        cx = nx as u64;
823        cy = ny as u64;
824    }
825
826    Ok(path)
827}
828
829/// Converts a direction code to (dx, dy) offset
830fn direction_to_offset(dir: f64) -> (i64, i64) {
831    let d = dir.round() as i64;
832    match d {
833        0 => (0, -1),  // N
834        1 => (1, -1),  // NE
835        2 => (1, 0),   // E
836        3 => (1, 1),   // SE
837        4 => (0, 1),   // S
838        5 => (-1, 1),  // SW
839        6 => (-1, 0),  // W
840        7 => (-1, -1), // NW
841        _ => (0, 0),   // Source or invalid
842    }
843}
844
845// ===========================================================================
846// A* path finding
847// ===========================================================================
848
849/// A* cost cell with heuristic
850#[derive(Copy, Clone, PartialEq)]
851struct AStarCell {
852    x: u64,
853    y: u64,
854    g_cost: f64, // Actual cost from start
855    f_cost: f64, // g_cost + heuristic
856}
857
858impl Eq for AStarCell {}
859
860impl PartialOrd for AStarCell {
861    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
862        Some(self.cmp(other))
863    }
864}
865
866impl Ord for AStarCell {
867    fn cmp(&self, other: &Self) -> Ordering {
868        other
869            .f_cost
870            .partial_cmp(&self.f_cost)
871            .unwrap_or(Ordering::Equal)
872    }
873}
874
875/// Computes A* shortest path between two specific points
876///
877/// Uses Euclidean distance as admissible heuristic. Returns the path
878/// as a binary raster and the total cost.
879///
880/// # Arguments
881///
882/// * `cost_surface` - Cost raster
883/// * `barriers` - Optional barrier raster
884/// * `start_x`, `start_y` - Start coordinates
885/// * `end_x`, `end_y` - End coordinates
886/// * `cell_size` - Cell size
887///
888/// # Errors
889///
890/// Returns an error if no path exists or coordinates are invalid
891pub fn astar_path(
892    cost_surface: &RasterBuffer,
893    barriers: Option<&RasterBuffer>,
894    start_x: u64,
895    start_y: u64,
896    end_x: u64,
897    end_y: u64,
898    cell_size: f64,
899) -> Result<(RasterBuffer, f64)> {
900    let width = cost_surface.width();
901    let height = cost_surface.height();
902
903    if start_x >= width || start_y >= height || end_x >= width || end_y >= height {
904        return Err(AlgorithmError::InvalidParameter {
905            parameter: "coordinates",
906            message: "Start or end coordinates are outside raster bounds".to_string(),
907        });
908    }
909
910    let mut g_costs = vec![f64::INFINITY; (width * height) as usize];
911    let mut came_from = vec![(u64::MAX, u64::MAX); (width * height) as usize];
912    let mut closed = vec![false; (width * height) as usize];
913
914    let start_idx = (start_y * width + start_x) as usize;
915    g_costs[start_idx] = 0.0;
916
917    let mut open = BinaryHeap::new();
918    let h = heuristic(start_x, start_y, end_x, end_y, cell_size);
919    open.push(AStarCell {
920        x: start_x,
921        y: start_y,
922        g_cost: 0.0,
923        f_cost: h,
924    });
925
926    while let Some(current) = open.pop() {
927        if current.x == end_x && current.y == end_y {
928            // Reconstruct path
929            let path = reconstruct_path(&came_from, width, height, end_x, end_y)?;
930            return Ok((path, current.g_cost));
931        }
932
933        let idx = (current.y * width + current.x) as usize;
934        if closed[idx] {
935            continue;
936        }
937        closed[idx] = true;
938
939        for &(dx, dy, _) in &NEIGHBORS {
940            let nx = current.x as i64 + dx;
941            let ny = current.y as i64 + dy;
942
943            if nx < 0 || nx >= width as i64 || ny < 0 || ny >= height as i64 {
944                continue;
945            }
946
947            let nx_u = nx as u64;
948            let ny_u = ny as u64;
949            let n_idx = (ny_u * width + nx_u) as usize;
950
951            if closed[n_idx] {
952                continue;
953            }
954
955            if let Some(barrier_raster) = barriers {
956                let barrier_val = barrier_raster
957                    .get_pixel(nx_u, ny_u)
958                    .map_err(AlgorithmError::Core)?;
959                if barrier_val > 0.0 {
960                    continue;
961                }
962            }
963
964            let neighbor_cost = cost_surface
965                .get_pixel(nx_u, ny_u)
966                .map_err(AlgorithmError::Core)?;
967
968            if neighbor_cost < 0.0 || neighbor_cost.is_nan() || neighbor_cost.is_infinite() {
969                continue;
970            }
971
972            let current_cost_val = cost_surface
973                .get_pixel(current.x, current.y)
974                .map_err(AlgorithmError::Core)?;
975
976            let base_dist = if dx.abs() + dy.abs() == 2 {
977                cell_size * core::f64::consts::SQRT_2
978            } else {
979                cell_size
980            };
981
982            let avg_cost = (current_cost_val + neighbor_cost) / 2.0;
983            let travel_cost = avg_cost * base_dist;
984            let tentative_g = current.g_cost + travel_cost;
985
986            if tentative_g < g_costs[n_idx] {
987                g_costs[n_idx] = tentative_g;
988                came_from[n_idx] = (current.x, current.y);
989
990                let h = heuristic(nx_u, ny_u, end_x, end_y, cell_size);
991                open.push(AStarCell {
992                    x: nx_u,
993                    y: ny_u,
994                    g_cost: tentative_g,
995                    f_cost: tentative_g + h,
996                });
997            }
998        }
999    }
1000
1001    Err(AlgorithmError::PathNotFound(format!(
1002        "No path exists from ({}, {}) to ({}, {})",
1003        start_x, start_y, end_x, end_y
1004    )))
1005}
1006
1007/// Euclidean distance heuristic for A*
1008fn heuristic(x1: u64, y1: u64, x2: u64, y2: u64, cell_size: f64) -> f64 {
1009    let dx = (x2 as f64 - x1 as f64) * cell_size;
1010    let dy = (y2 as f64 - y1 as f64) * cell_size;
1011    (dx * dx + dy * dy).sqrt()
1012}
1013
1014/// Reconstructs the A* path from came_from array
1015fn reconstruct_path(
1016    came_from: &[(u64, u64)],
1017    width: u64,
1018    height: u64,
1019    end_x: u64,
1020    end_y: u64,
1021) -> Result<RasterBuffer> {
1022    let mut path = RasterBuffer::zeros(width, height, RasterDataType::UInt8);
1023
1024    let mut cx = end_x;
1025    let mut cy = end_y;
1026    let max_steps = (width * height) as usize;
1027
1028    for _ in 0..max_steps {
1029        path.set_pixel(cx, cy, 1.0).map_err(AlgorithmError::Core)?;
1030
1031        let idx = (cy * width + cx) as usize;
1032        let (px, py) = came_from[idx];
1033
1034        if px == u64::MAX {
1035            break; // Reached start
1036        }
1037
1038        cx = px;
1039        cy = py;
1040    }
1041
1042    Ok(path)
1043}
1044
1045// ===========================================================================
1046// Corridor analysis
1047// ===========================================================================
1048
1049/// Computes a least-cost corridor between two source regions
1050///
1051/// The corridor raster shows the combined cost from both sources. Low values
1052/// indicate the corridor (efficient route between the two sources).
1053///
1054/// corridor(x,y) = cost_from_a(x,y) + cost_from_b(x,y)
1055///
1056/// # Arguments
1057///
1058/// * `cost_from_a` - Cost-distance raster from source A
1059/// * `cost_from_b` - Cost-distance raster from source B
1060///
1061/// # Errors
1062///
1063/// Returns an error if the rasters have different dimensions
1064pub fn compute_corridor(
1065    cost_from_a: &RasterBuffer,
1066    cost_from_b: &RasterBuffer,
1067) -> Result<RasterBuffer> {
1068    let width = cost_from_a.width();
1069    let height = cost_from_a.height();
1070
1071    if cost_from_b.width() != width || cost_from_b.height() != height {
1072        return Err(AlgorithmError::InvalidDimensions {
1073            message: "Cost distance rasters must have the same dimensions",
1074            actual: cost_from_b.width() as usize,
1075            expected: width as usize,
1076        });
1077    }
1078
1079    let mut corridor = RasterBuffer::zeros(width, height, RasterDataType::Float64);
1080
1081    for y in 0..height {
1082        for x in 0..width {
1083            let ca = cost_from_a.get_pixel(x, y).map_err(AlgorithmError::Core)?;
1084            let cb = cost_from_b.get_pixel(x, y).map_err(AlgorithmError::Core)?;
1085
1086            let combined = if ca.is_infinite() || cb.is_infinite() {
1087                f64::INFINITY
1088            } else {
1089                ca + cb
1090            };
1091
1092            corridor
1093                .set_pixel(x, y, combined)
1094                .map_err(AlgorithmError::Core)?;
1095        }
1096    }
1097
1098    Ok(corridor)
1099}
1100
1101/// Computes normalized corridor (values relative to minimum corridor cost)
1102///
1103/// Subtracts the minimum combined cost so the optimal path has value 0.
1104/// Cells above the given threshold are set to infinity (not in corridor).
1105///
1106/// # Arguments
1107///
1108/// * `cost_from_a` - Cost-distance from source A
1109/// * `cost_from_b` - Cost-distance from source B
1110/// * `threshold` - Maximum cost above minimum to include in corridor
1111///
1112/// # Errors
1113///
1114/// Returns an error if the rasters have different dimensions
1115pub fn compute_corridor_normalized(
1116    cost_from_a: &RasterBuffer,
1117    cost_from_b: &RasterBuffer,
1118    threshold: f64,
1119) -> Result<RasterBuffer> {
1120    let corridor = compute_corridor(cost_from_a, cost_from_b)?;
1121    let width = corridor.width();
1122    let height = corridor.height();
1123
1124    // Find minimum corridor value
1125    let mut min_val = f64::INFINITY;
1126    for y in 0..height {
1127        for x in 0..width {
1128            let v = corridor.get_pixel(x, y).map_err(AlgorithmError::Core)?;
1129            if v < min_val {
1130                min_val = v;
1131            }
1132        }
1133    }
1134
1135    let mut normalized = RasterBuffer::zeros(width, height, RasterDataType::Float64);
1136
1137    for y in 0..height {
1138        for x in 0..width {
1139            let v = corridor.get_pixel(x, y).map_err(AlgorithmError::Core)?;
1140            let relative = v - min_val;
1141            let final_val = if relative > threshold {
1142                f64::INFINITY
1143            } else {
1144                relative
1145            };
1146            normalized
1147                .set_pixel(x, y, final_val)
1148                .map_err(AlgorithmError::Core)?;
1149        }
1150    }
1151
1152    Ok(normalized)
1153}
1154
1155// ===========================================================================
1156// Tests
1157// ===========================================================================
1158
1159#[cfg(test)]
1160mod tests {
1161    use super::*;
1162
1163    fn create_uniform_cost(size: u64, cost_value: f64) -> RasterBuffer {
1164        let mut buf = RasterBuffer::zeros(size, size, RasterDataType::Float32);
1165        for y in 0..size {
1166            for x in 0..size {
1167                let _ = buf.set_pixel(x, y, cost_value);
1168            }
1169        }
1170        buf
1171    }
1172
1173    fn create_sources_single(size: u64, sx: u64, sy: u64) -> RasterBuffer {
1174        let mut buf = RasterBuffer::zeros(size, size, RasterDataType::Float32);
1175        let _ = buf.set_pixel(sx, sy, 1.0);
1176        buf
1177    }
1178
1179    fn create_barrier_wall(size: u64, wall_col: u64) -> RasterBuffer {
1180        let mut buf = RasterBuffer::zeros(size, size, RasterDataType::Float32);
1181        for y in 0..size {
1182            let _ = buf.set_pixel(wall_col, y, 1.0);
1183        }
1184        buf
1185    }
1186
1187    // --- Euclidean distance ---
1188
1189    #[test]
1190    fn test_euclidean_distance() {
1191        let sources = create_sources_single(10, 5, 5);
1192        let distance = euclidean_distance(&sources, 1.0).expect("euclidean");
1193
1194        // At source should be 0
1195        let at_source = distance.get_pixel(5, 5).expect("pixel");
1196        assert!(at_source < 1e-6);
1197
1198        // Adjacent should be ~1
1199        let adjacent = distance.get_pixel(5, 6).expect("pixel");
1200        assert!((adjacent - 1.0).abs() < 0.01);
1201
1202        // Distance should increase
1203        let d1 = distance.get_pixel(5, 6).expect("pixel");
1204        let d2 = distance.get_pixel(5, 7).expect("pixel");
1205        assert!(d2 > d1);
1206    }
1207
1208    // --- Basic cost distance ---
1209
1210    #[test]
1211    fn test_cost_distance_uniform() {
1212        let sources = create_sources_single(10, 0, 0);
1213        let cost = create_uniform_cost(10, 1.0);
1214        let result = cost_distance(&sources, &cost, 1.0).expect("cost_dist");
1215
1216        // Source should be 0
1217        let at_source = result.get_pixel(0, 0).expect("pixel");
1218        assert!(at_source < 1e-6);
1219
1220        // Cost should increase with distance
1221        let near = result.get_pixel(1, 0).expect("near");
1222        let far = result.get_pixel(5, 5).expect("far");
1223        assert!(far > near);
1224    }
1225
1226    // --- Cost distance with barriers ---
1227
1228    #[test]
1229    fn test_cost_distance_with_barrier() {
1230        let sources = create_sources_single(10, 0, 5);
1231        let cost = create_uniform_cost(10, 1.0);
1232        let barrier = create_barrier_wall(10, 5);
1233
1234        let result_no_barrier =
1235            cost_distance_full(&sources, &cost, None, 1.0, FrictionModel::Isotropic)
1236                .expect("no_barrier");
1237
1238        let result_with_barrier = cost_distance_full(
1239            &sources,
1240            &cost,
1241            Some(&barrier),
1242            1.0,
1243            FrictionModel::Isotropic,
1244        )
1245        .expect("with_barrier");
1246
1247        // Cells beyond the barrier should have higher cost (must go around)
1248        let cost_no_barrier = result_no_barrier
1249            .cost_distance
1250            .get_pixel(9, 5)
1251            .expect("pixel");
1252        let cost_with_barrier = result_with_barrier
1253            .cost_distance
1254            .get_pixel(9, 5)
1255            .expect("pixel");
1256
1257        // With full wall barrier, cells behind it should be unreachable (infinity)
1258        assert!(
1259            cost_with_barrier.is_infinite(),
1260            "Cells behind full wall barrier should be unreachable, got {cost_with_barrier}"
1261        );
1262        assert!(cost_no_barrier.is_finite());
1263    }
1264
1265    // --- Direction raster ---
1266
1267    #[test]
1268    fn test_direction_raster() {
1269        let sources = create_sources_single(10, 5, 5);
1270        let cost = create_uniform_cost(10, 1.0);
1271
1272        let result =
1273            cost_distance_full(&sources, &cost, None, 1.0, FrictionModel::Isotropic).expect("full");
1274
1275        assert!(result.direction.is_some());
1276        let dir = result.direction.expect("dir");
1277
1278        // Source cell should have direction = -1 (SOURCE)
1279        let source_dir = dir.get_pixel(5, 5).expect("pixel");
1280        assert!(
1281            (source_dir - Direction::SOURCE).abs() < 0.5,
1282            "Source direction should be SOURCE (-1), got {source_dir}"
1283        );
1284
1285        // Cell to the east of source should point west (toward source)
1286        let east_dir = dir.get_pixel(6, 5).expect("pixel");
1287        assert!(
1288            (east_dir - Direction::W).abs() < 0.5,
1289            "East neighbor should point West, got {east_dir}"
1290        );
1291    }
1292
1293    // --- Allocation raster ---
1294
1295    #[test]
1296    fn test_allocation_raster() {
1297        let mut sources = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
1298        let _ = sources.set_pixel(2, 5, 1.0);
1299        let _ = sources.set_pixel(7, 5, 1.0);
1300
1301        let cost = create_uniform_cost(10, 1.0);
1302
1303        let result = cost_distance_full(&sources, &cost, None, 1.0, FrictionModel::Isotropic)
1304            .expect("allocation");
1305
1306        assert!(result.allocation.is_some());
1307        let alloc = result.allocation.expect("alloc");
1308
1309        // Cells near source 0 (2,5) should be allocated to source 0
1310        let near_0 = alloc.get_pixel(1, 5).expect("pixel");
1311        assert!(
1312            (near_0 - 0.0).abs() < 0.5,
1313            "Cell near source 0 should be allocated to 0, got {near_0}"
1314        );
1315
1316        // Cells near source 1 (7,5) should be allocated to source 1
1317        let near_1 = alloc.get_pixel(8, 5).expect("pixel");
1318        assert!(
1319            (near_1 - 1.0).abs() < 0.5,
1320            "Cell near source 1 should be allocated to 1, got {near_1}"
1321        );
1322    }
1323
1324    // --- Least cost path ---
1325
1326    #[test]
1327    fn test_least_cost_path() {
1328        let sources = create_sources_single(10, 0, 0);
1329        let cost = create_uniform_cost(10, 1.0);
1330        let cost_dist = cost_distance(&sources, &cost, 1.0).expect("cost_dist");
1331
1332        let path = least_cost_path(&cost_dist, 5, 5).expect("path");
1333
1334        // Destination should be on path
1335        let at_dest = path.get_pixel(5, 5).expect("pixel");
1336        assert!(at_dest > 0.0);
1337
1338        // Source should be on path
1339        let at_source = path.get_pixel(0, 0).expect("pixel");
1340        assert!(at_source > 0.0);
1341    }
1342
1343    #[test]
1344    fn test_least_cost_path_from_direction() {
1345        let sources = create_sources_single(10, 0, 0);
1346        let cost = create_uniform_cost(10, 1.0);
1347
1348        let result =
1349            cost_distance_full(&sources, &cost, None, 1.0, FrictionModel::Isotropic).expect("full");
1350
1351        let dir_raster = result.direction.expect("dir");
1352        let path = least_cost_path_from_direction(&dir_raster, 5, 5).expect("path");
1353
1354        let at_dest = path.get_pixel(5, 5).expect("pixel");
1355        assert!(at_dest > 0.0);
1356    }
1357
1358    // --- A* path finding ---
1359
1360    #[test]
1361    fn test_astar_path() {
1362        let cost = create_uniform_cost(10, 1.0);
1363        let (path, total_cost) = astar_path(&cost, None, 0, 0, 9, 9, 1.0).expect("astar");
1364
1365        let at_start = path.get_pixel(0, 0).expect("pixel");
1366        let at_end = path.get_pixel(9, 9).expect("pixel");
1367        assert!(at_start > 0.0);
1368        assert!(at_end > 0.0);
1369
1370        // Diagonal distance from (0,0) to (9,9): ~9*sqrt(2)*1.0 ~= 12.7
1371        assert!(total_cost > 10.0 && total_cost < 20.0);
1372    }
1373
1374    #[test]
1375    fn test_astar_path_with_barrier() {
1376        let cost = create_uniform_cost(10, 1.0);
1377        // Create a partial wall (leave gap at top)
1378        let mut barrier = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
1379        for y in 1..10 {
1380            let _ = barrier.set_pixel(5, y, 1.0);
1381        }
1382
1383        let result = astar_path(&cost, Some(&barrier), 0, 5, 9, 5, 1.0);
1384        assert!(result.is_ok());
1385        let (_, total_cost) = result.expect("astar_barrier");
1386        // Must go around the barrier, so cost should be higher
1387        assert!(total_cost > 9.0, "Path around barrier should be longer");
1388    }
1389
1390    #[test]
1391    fn test_astar_no_path() {
1392        let cost = create_uniform_cost(10, 1.0);
1393        let barrier = create_barrier_wall(10, 5); // Full wall
1394
1395        let result = astar_path(&cost, Some(&barrier), 0, 5, 9, 5, 1.0);
1396        assert!(result.is_err());
1397    }
1398
1399    // --- Corridor analysis ---
1400
1401    #[test]
1402    fn test_corridor() {
1403        let source_a = create_sources_single(10, 0, 5);
1404        let source_b = create_sources_single(10, 9, 5);
1405        let cost = create_uniform_cost(10, 1.0);
1406
1407        let cost_a = cost_distance(&source_a, &cost, 1.0).expect("cost_a");
1408        let cost_b = cost_distance(&source_b, &cost, 1.0).expect("cost_b");
1409
1410        let corridor = compute_corridor(&cost_a, &cost_b).expect("corridor");
1411
1412        // Minimum corridor should be along the straight line y=5
1413        let center = corridor.get_pixel(5, 5).expect("center");
1414        let off_center = corridor.get_pixel(5, 0).expect("off_center");
1415        assert!(
1416            center < off_center,
1417            "Corridor center should have lower cost than off-center"
1418        );
1419    }
1420
1421    #[test]
1422    fn test_corridor_normalized() {
1423        let source_a = create_sources_single(10, 0, 5);
1424        let source_b = create_sources_single(10, 9, 5);
1425        let cost = create_uniform_cost(10, 1.0);
1426
1427        let cost_a = cost_distance(&source_a, &cost, 1.0).expect("cost_a");
1428        let cost_b = cost_distance(&source_b, &cost, 1.0).expect("cost_b");
1429
1430        let normalized = compute_corridor_normalized(&cost_a, &cost_b, 5.0).expect("normalized");
1431
1432        // Minimum should be 0
1433        let mut found_zero = false;
1434        for y in 0..10 {
1435            for x in 0..10 {
1436                let v = normalized.get_pixel(x, y).expect("pixel");
1437                if v.abs() < 0.01 {
1438                    found_zero = true;
1439                }
1440            }
1441        }
1442        assert!(found_zero, "Normalized corridor should have a zero minimum");
1443    }
1444
1445    // --- Anisotropic cost distance ---
1446
1447    #[test]
1448    fn test_anisotropic_tobler() {
1449        let sources = create_sources_single(10, 5, 5);
1450        let cost = create_uniform_cost(10, 1.0);
1451        // Create a simple slope DEM
1452        let mut dem = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
1453        for y in 0..10 {
1454            for x in 0..10 {
1455                let _ = dem.set_pixel(x, y, y as f64 * 10.0);
1456            }
1457        }
1458
1459        let result = cost_distance_anisotropic(
1460            &sources,
1461            &cost,
1462            &dem,
1463            None,
1464            1.0,
1465            FrictionModel::ToblerHiking,
1466        );
1467        assert!(result.is_ok());
1468    }
1469
1470    #[test]
1471    fn test_anisotropic_symmetric_slope() {
1472        let sources = create_sources_single(10, 5, 5);
1473        let cost = create_uniform_cost(10, 1.0);
1474        let mut dem = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
1475        for y in 0..10 {
1476            for x in 0..10 {
1477                let _ = dem.set_pixel(x, y, y as f64 * 5.0);
1478            }
1479        }
1480
1481        let result = cost_distance_anisotropic(
1482            &sources,
1483            &cost,
1484            &dem,
1485            None,
1486            1.0,
1487            FrictionModel::SymmetricSlope { slope_weight: 1.0 },
1488        );
1489        assert!(result.is_ok());
1490    }
1491
1492    #[test]
1493    fn test_anisotropic_asymmetric_slope() {
1494        let sources = create_sources_single(10, 5, 5);
1495        let cost = create_uniform_cost(10, 1.0);
1496        let mut dem = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
1497        for y in 0..10 {
1498            for x in 0..10 {
1499                let _ = dem.set_pixel(x, y, y as f64 * 5.0);
1500            }
1501        }
1502
1503        let result = cost_distance_anisotropic(
1504            &sources,
1505            &cost,
1506            &dem,
1507            None,
1508            1.0,
1509            FrictionModel::AsymmetricSlope {
1510                uphill_weight: 2.0,
1511                downhill_weight: 0.5,
1512            },
1513        );
1514        assert!(result.is_ok());
1515
1516        let cd = result.expect("aniso");
1517
1518        // Cost going uphill should be higher than downhill
1519        let uphill = cd.cost_distance.get_pixel(5, 0).expect("uphill"); // y decreases = uphill
1520        let downhill = cd.cost_distance.get_pixel(5, 9).expect("downhill"); // y increases = downhill
1521        // Note: depending on DEM setup, these may vary but both should be finite
1522        assert!(uphill.is_finite());
1523        assert!(downhill.is_finite());
1524    }
1525
1526    // --- Friction model tests ---
1527
1528    #[test]
1529    fn test_friction_model_default() {
1530        let model = FrictionModel::default();
1531        assert_eq!(model, FrictionModel::Isotropic);
1532    }
1533
1534    // --- Edge case tests ---
1535
1536    #[test]
1537    fn test_least_cost_path_invalid_destination() {
1538        let cost_dist = RasterBuffer::zeros(10, 10, RasterDataType::Float64);
1539        let result = least_cost_path(&cost_dist, 100, 100);
1540        assert!(result.is_err());
1541    }
1542
1543    #[test]
1544    fn test_direction_from_offset_all() {
1545        assert!((direction_from_offset(0, -1) - Direction::N).abs() < 0.5);
1546        assert!((direction_from_offset(1, -1) - Direction::NE).abs() < 0.5);
1547        assert!((direction_from_offset(1, 0) - Direction::E).abs() < 0.5);
1548        assert!((direction_from_offset(1, 1) - Direction::SE).abs() < 0.5);
1549        assert!((direction_from_offset(0, 1) - Direction::S).abs() < 0.5);
1550        assert!((direction_from_offset(-1, 1) - Direction::SW).abs() < 0.5);
1551        assert!((direction_from_offset(-1, 0) - Direction::W).abs() < 0.5);
1552        assert!((direction_from_offset(-1, -1) - Direction::NW).abs() < 0.5);
1553    }
1554
1555    #[test]
1556    fn test_direction_to_offset_all() {
1557        assert_eq!(direction_to_offset(Direction::N), (0, -1));
1558        assert_eq!(direction_to_offset(Direction::NE), (1, -1));
1559        assert_eq!(direction_to_offset(Direction::E), (1, 0));
1560        assert_eq!(direction_to_offset(Direction::SE), (1, 1));
1561        assert_eq!(direction_to_offset(Direction::S), (0, 1));
1562        assert_eq!(direction_to_offset(Direction::SW), (-1, 1));
1563        assert_eq!(direction_to_offset(Direction::W), (-1, 0));
1564        assert_eq!(direction_to_offset(Direction::NW), (-1, -1));
1565    }
1566}