Skip to main content

oxigdal_algorithms/raster/
slope_aspect.rs

1//! Slope and aspect calculation from DEMs
2//!
3//! Computes terrain slope (gradient) and aspect (direction of slope) from
4//! digital elevation models using multiple algorithm variants.
5//!
6//! # Algorithms
7//!
8//! - **Horn (1981)**: 3rd-order finite difference, weighted. Most commonly used (GDAL default).
9//!   Uses a 3x3 window with central cell weighted 2x. Best for noisy data.
10//!
11//! - **Zevenbergen & Thorne (1987)**: 2nd-order finite difference, unweighted.
12//!   Uses only the 4 cardinal neighbors. Better for smooth surfaces.
13//!
14//! - **Evans-Young (1972/1978)**: Fits a quadratic surface to the 3x3 neighborhood.
15//!   Theoretically optimal for regular grids.
16//!
17//! - **Maximum Downhill Slope (MDS)**: Considers the steepest slope among all 8 neighbors.
18//!   Useful for hydrological applications.
19//!
20//! # Units
21//!
22//! Slope can be expressed in degrees, radians, or percent rise.
23//! Aspect is in degrees from north (0-360, clockwise).
24//!
25//! # Edge Handling
26//!
27//! Multiple strategies for handling boundary pixels where the 3x3 window
28//! extends beyond the raster extent:
29//! - **Skip**: Leave boundary pixels as NoData (default)
30//! - **Reflect**: Mirror the raster at boundaries
31//! - **Extrapolate**: Linear extrapolation from interior values
32//! - **Replicate**: Repeat the nearest edge pixel
33//!
34//! # References
35//!
36//! - Horn, B.K.P. (1981). Hill shading and the reflectance map.
37//! - Zevenbergen, L.W. & Thorne, C.R. (1987). Quantitative analysis of land surface topography.
38//! - Evans, I.S. (1972). General geomorphometry, derivatives of altitude, and descriptive statistics.
39//! - Burrough, P.A. & McDonnell, R.A. (1998). Principles of GIS.
40
41use crate::error::{AlgorithmError, Result};
42use core::f64::consts::PI;
43use oxigdal_core::buffer::RasterBuffer;
44use oxigdal_core::types::RasterDataType;
45
46#[cfg(feature = "parallel")]
47use rayon::prelude::*;
48
49// ===========================================================================
50// Algorithm selection
51// ===========================================================================
52
53/// Algorithm for computing slope and aspect gradients
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55pub enum SlopeAlgorithm {
56    /// Horn (1981): 3rd-order finite difference with 2x weighting on cardinal neighbors.
57    /// Most robust against noise. This is the GDAL default.
58    #[default]
59    Horn,
60
61    /// Zevenbergen & Thorne (1987): 2nd-order finite difference using only 4 cardinal neighbors.
62    /// Better for smooth, mathematically defined surfaces.
63    ZevenbergenThorne,
64
65    /// Evans-Young: Fits a quadratic polynomial to the 3x3 window.
66    /// Theoretically optimal for regular grids.
67    EvansYoung,
68
69    /// Maximum downhill slope: steepest descent among all 8 neighbors.
70    /// Useful for hydrological analysis (D-infinity style).
71    MaximumDownhill,
72}
73
74// ===========================================================================
75// Slope units
76// ===========================================================================
77
78/// Units for slope output
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80pub enum SlopeUnits {
81    /// Degrees (0-90)
82    #[default]
83    Degrees,
84
85    /// Radians (0 - pi/2)
86    Radians,
87
88    /// Percent rise (0 - infinity, typically 0-several hundred)
89    Percent,
90
91    /// Tangent of slope angle (rise/run), same as percent/100
92    Tangent,
93}
94
95// ===========================================================================
96// Edge handling
97// ===========================================================================
98
99/// Strategy for handling edges where the 3x3 window extends beyond raster boundaries
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
101pub enum EdgeHandling {
102    /// Skip edge pixels (leave as zero/nodata)
103    #[default]
104    Skip,
105
106    /// Reflect (mirror) at raster boundaries
107    Reflect,
108
109    /// Extrapolate linearly from interior pixels
110    Extrapolate,
111
112    /// Replicate nearest edge pixel value
113    Replicate,
114}
115
116// ===========================================================================
117// Configuration and output structs
118// ===========================================================================
119
120/// Configuration for slope/aspect computation
121#[derive(Debug, Clone)]
122pub struct SlopeAspectConfig {
123    /// Algorithm to use
124    pub algorithm: SlopeAlgorithm,
125    /// Slope output units
126    pub slope_units: SlopeUnits,
127    /// Edge handling strategy
128    pub edge_handling: EdgeHandling,
129    /// Z-factor for vertical exaggeration (default: 1.0)
130    pub z_factor: f64,
131}
132
133impl Default for SlopeAspectConfig {
134    fn default() -> Self {
135        Self {
136            algorithm: SlopeAlgorithm::default(),
137            slope_units: SlopeUnits::default(),
138            edge_handling: EdgeHandling::default(),
139            z_factor: 1.0,
140        }
141    }
142}
143
144/// Output from slope/aspect computation
145#[derive(Debug)]
146pub struct SlopeAspectOutput {
147    /// Slope values in the configured units
148    pub slope: RasterBuffer,
149    /// Aspect in degrees (0-360, 0=N, clockwise). -1 for flat areas.
150    pub aspect: RasterBuffer,
151}
152
153// ===========================================================================
154// Main computation functions
155// ===========================================================================
156
157/// Computes both slope and aspect using Horn's method (backward-compatible)
158///
159/// # Arguments
160///
161/// * `dem` - Digital elevation model
162/// * `pixel_size` - Cell size
163/// * `z_factor` - Vertical exaggeration factor
164///
165/// # Errors
166///
167/// Returns an error if the DEM is smaller than 3x3
168pub fn compute_slope_aspect(
169    dem: &RasterBuffer,
170    pixel_size: f64,
171    z_factor: f64,
172) -> Result<SlopeAspectOutput> {
173    compute_slope_aspect_advanced(
174        dem,
175        pixel_size,
176        &SlopeAspectConfig {
177            z_factor,
178            ..Default::default()
179        },
180    )
181}
182
183/// Computes slope and aspect with full configuration
184///
185/// # Arguments
186///
187/// * `dem` - Digital elevation model
188/// * `pixel_size` - Cell size
189/// * `config` - Full configuration including algorithm, units, edge handling
190///
191/// # Errors
192///
193/// Returns an error if the DEM is smaller than 3x3
194pub fn compute_slope_aspect_advanced(
195    dem: &RasterBuffer,
196    pixel_size: f64,
197    config: &SlopeAspectConfig,
198) -> Result<SlopeAspectOutput> {
199    let width = dem.width();
200    let height = dem.height();
201
202    if width < 3 || height < 3 {
203        return Err(AlgorithmError::InsufficientData {
204            operation: "slope/aspect",
205            message: "DEM must be at least 3x3".to_string(),
206        });
207    }
208
209    let mut slope_buf = RasterBuffer::zeros(width, height, dem.data_type());
210    let mut aspect_buf = RasterBuffer::zeros(width, height, dem.data_type());
211
212    // Determine processing range based on edge handling
213    let (y_start, y_end, x_start, x_end) = match config.edge_handling {
214        EdgeHandling::Skip => (1, height - 1, 1, width - 1),
215        _ => (0, height, 0, width),
216    };
217
218    #[cfg(feature = "parallel")]
219    {
220        let results: Result<Vec<_>> = (y_start..y_end)
221            .into_par_iter()
222            .map(|y| {
223                let mut row = Vec::new();
224                for x in x_start..x_end {
225                    let (s, a) = compute_pixel_slope_aspect(dem, x, y, pixel_size, config)?;
226                    row.push((x, s, a));
227                }
228                Ok((y, row))
229            })
230            .collect();
231
232        for (y, row) in results? {
233            for (x, s, a) in row {
234                slope_buf.set_pixel(x, y, s).map_err(AlgorithmError::Core)?;
235                aspect_buf
236                    .set_pixel(x, y, a)
237                    .map_err(AlgorithmError::Core)?;
238            }
239        }
240    }
241
242    #[cfg(not(feature = "parallel"))]
243    {
244        for y in y_start..y_end {
245            for x in x_start..x_end {
246                let (s, a) = compute_pixel_slope_aspect(dem, x, y, pixel_size, config)?;
247                slope_buf.set_pixel(x, y, s).map_err(AlgorithmError::Core)?;
248                aspect_buf
249                    .set_pixel(x, y, a)
250                    .map_err(AlgorithmError::Core)?;
251            }
252        }
253    }
254
255    Ok(SlopeAspectOutput {
256        slope: slope_buf,
257        aspect: aspect_buf,
258    })
259}
260
261/// Computes slope only (convenience function)
262///
263/// # Errors
264///
265/// Returns an error if the DEM is smaller than 3x3
266pub fn slope(dem: &RasterBuffer, pixel_size: f64, z_factor: f64) -> Result<RasterBuffer> {
267    let output = compute_slope_aspect(dem, pixel_size, z_factor)?;
268    Ok(output.slope)
269}
270
271/// Computes aspect only (convenience function)
272///
273/// # Errors
274///
275/// Returns an error if the DEM is smaller than 3x3
276pub fn aspect(dem: &RasterBuffer, pixel_size: f64, z_factor: f64) -> Result<RasterBuffer> {
277    let output = compute_slope_aspect(dem, pixel_size, z_factor)?;
278    Ok(output.aspect)
279}
280
281/// Computes slope with full configuration (convenience function)
282///
283/// # Errors
284///
285/// Returns an error if the DEM is smaller than 3x3
286pub fn slope_advanced(
287    dem: &RasterBuffer,
288    pixel_size: f64,
289    config: &SlopeAspectConfig,
290) -> Result<RasterBuffer> {
291    let output = compute_slope_aspect_advanced(dem, pixel_size, config)?;
292    Ok(output.slope)
293}
294
295/// Computes aspect with full configuration (convenience function)
296///
297/// # Errors
298///
299/// Returns an error if the DEM is smaller than 3x3
300pub fn aspect_advanced(
301    dem: &RasterBuffer,
302    pixel_size: f64,
303    config: &SlopeAspectConfig,
304) -> Result<RasterBuffer> {
305    let output = compute_slope_aspect_advanced(dem, pixel_size, config)?;
306    Ok(output.aspect)
307}
308
309// ===========================================================================
310// Per-pixel computation
311// ===========================================================================
312
313/// Computes slope and aspect for a single pixel
314fn compute_pixel_slope_aspect(
315    dem: &RasterBuffer,
316    x: u64,
317    y: u64,
318    pixel_size: f64,
319    config: &SlopeAspectConfig,
320) -> Result<(f64, f64)> {
321    let z = get_neighborhood(dem, x, y, config.edge_handling)?;
322
323    let (dz_dx, dz_dy) = match config.algorithm {
324        SlopeAlgorithm::Horn => compute_gradients_horn(&z, pixel_size, config.z_factor),
325        SlopeAlgorithm::ZevenbergenThorne => {
326            compute_gradients_zevenbergen_thorne(&z, pixel_size, config.z_factor)
327        }
328        SlopeAlgorithm::EvansYoung => {
329            compute_gradients_evans_young(&z, pixel_size, config.z_factor)
330        }
331        SlopeAlgorithm::MaximumDownhill => {
332            return compute_max_downhill_slope(&z, pixel_size, config);
333        }
334    };
335
336    // Slope
337    let slope_tan = (dz_dx * dz_dx + dz_dy * dz_dy).sqrt();
338    let slope_value = convert_slope(slope_tan, config.slope_units);
339
340    // Aspect (0-360 from north, clockwise)
341    let aspect_value = if dz_dx.abs() < 1e-15 && dz_dy.abs() < 1e-15 {
342        -1.0 // flat area
343    } else {
344        let aspect_rad = dz_dy.atan2(-dz_dx);
345        let mut aspect_deg = aspect_rad * 180.0 / PI;
346        if aspect_deg < 0.0 {
347            aspect_deg += 360.0;
348        }
349        aspect_deg
350    };
351
352    Ok((slope_value, aspect_value))
353}
354
355// ===========================================================================
356// Gradient algorithms
357// ===========================================================================
358
359/// Horn (1981): 3rd-order finite difference with 2x weighting on cardinal neighbors
360///
361/// dz/dx = ((z2+2*z5+z8) - (z0+2*z3+z6)) / (8*cellsize)
362/// dz/dy = ((z6+2*z7+z8) - (z0+2*z1+z2)) / (8*cellsize)
363fn compute_gradients_horn(z: &[[f64; 3]; 3], pixel_size: f64, z_factor: f64) -> (f64, f64) {
364    let scale = z_factor / (8.0 * pixel_size);
365
366    let dz_dx = ((z[0][2] + 2.0 * z[1][2] + z[2][2]) - (z[0][0] + 2.0 * z[1][0] + z[2][0])) * scale;
367
368    let dz_dy = ((z[2][0] + 2.0 * z[2][1] + z[2][2]) - (z[0][0] + 2.0 * z[0][1] + z[0][2])) * scale;
369
370    (dz_dx, dz_dy)
371}
372
373/// Zevenbergen & Thorne (1987): 2nd-order finite difference using only cardinal neighbors
374///
375/// dz/dx = (z5 - z3) / (2*cellsize)
376/// dz/dy = (z7 - z1) / (2*cellsize)
377fn compute_gradients_zevenbergen_thorne(
378    z: &[[f64; 3]; 3],
379    pixel_size: f64,
380    z_factor: f64,
381) -> (f64, f64) {
382    let scale = z_factor / (2.0 * pixel_size);
383
384    let dz_dx = (z[1][2] - z[1][0]) * scale;
385    let dz_dy = (z[2][1] - z[0][1]) * scale;
386
387    (dz_dx, dz_dy)
388}
389
390/// Evans-Young: Quadratic polynomial fit
391///
392/// Fits z = ax^2 + by^2 + cxy + dx + ey + f to the 3x3 neighborhood.
393/// The gradients are dz/dx = d and dz/dy = e at the center.
394///
395/// Using the method of least squares on a regular grid:
396/// d = (z02 + z12 + z22 - z00 - z10 - z20) / (6 * cellsize)
397/// e = (z20 + z21 + z22 - z00 - z01 - z02) / (6 * cellsize)
398fn compute_gradients_evans_young(z: &[[f64; 3]; 3], pixel_size: f64, z_factor: f64) -> (f64, f64) {
399    let scale = z_factor / (6.0 * pixel_size);
400
401    let dz_dx = (z[0][2] + z[1][2] + z[2][2] - z[0][0] - z[1][0] - z[2][0]) * scale;
402    let dz_dy = (z[2][0] + z[2][1] + z[2][2] - z[0][0] - z[0][1] - z[0][2]) * scale;
403
404    (dz_dx, dz_dy)
405}
406
407/// Maximum downhill slope computation
408///
409/// Finds the steepest descent among all 8 neighbors and returns
410/// slope and aspect for that direction.
411fn compute_max_downhill_slope(
412    z: &[[f64; 3]; 3],
413    pixel_size: f64,
414    config: &SlopeAspectConfig,
415) -> Result<(f64, f64)> {
416    let center = z[1][1];
417
418    // 8 neighbors with offsets and aspect angles
419    let neighbors: [(usize, usize, f64, f64); 8] = [
420        (0, 1, 0.0, pixel_size),                               // N
421        (0, 2, 45.0, pixel_size * core::f64::consts::SQRT_2),  // NE
422        (1, 2, 90.0, pixel_size),                              // E
423        (2, 2, 135.0, pixel_size * core::f64::consts::SQRT_2), // SE
424        (2, 1, 180.0, pixel_size),                             // S
425        (2, 0, 225.0, pixel_size * core::f64::consts::SQRT_2), // SW
426        (1, 0, 270.0, pixel_size),                             // W
427        (0, 0, 315.0, pixel_size * core::f64::consts::SQRT_2), // NW
428    ];
429
430    let mut max_slope_tan = 0.0_f64;
431    let mut max_aspect = -1.0_f64;
432
433    for &(row, col, aspect_deg, dist) in &neighbors {
434        let neighbor_elev = z[row][col];
435        let drop = (center - neighbor_elev) * config.z_factor;
436        let slope_tan = drop / dist;
437
438        if slope_tan > max_slope_tan {
439            max_slope_tan = slope_tan;
440            max_aspect = aspect_deg;
441        }
442    }
443
444    let slope_value = convert_slope(max_slope_tan, config.slope_units);
445
446    Ok((slope_value, max_aspect))
447}
448
449// ===========================================================================
450// Unit conversion
451// ===========================================================================
452
453/// Converts slope from tangent (rise/run) to the requested units
454fn convert_slope(slope_tangent: f64, units: SlopeUnits) -> f64 {
455    match units {
456        SlopeUnits::Degrees => slope_tangent.atan() * 180.0 / PI,
457        SlopeUnits::Radians => slope_tangent.atan(),
458        SlopeUnits::Percent => slope_tangent * 100.0,
459        SlopeUnits::Tangent => slope_tangent,
460    }
461}
462
463/// Converts slope from degrees to another unit
464pub fn convert_slope_degrees(slope_degrees: f64, target_units: SlopeUnits) -> f64 {
465    match target_units {
466        SlopeUnits::Degrees => slope_degrees,
467        SlopeUnits::Radians => slope_degrees * PI / 180.0,
468        SlopeUnits::Percent => (slope_degrees * PI / 180.0).tan() * 100.0,
469        SlopeUnits::Tangent => (slope_degrees * PI / 180.0).tan(),
470    }
471}
472
473// ===========================================================================
474// Edge handling / neighborhood extraction
475// ===========================================================================
476
477/// Extracts a 3x3 neighborhood around (x, y) with edge handling
478fn get_neighborhood(
479    dem: &RasterBuffer,
480    x: u64,
481    y: u64,
482    edge_handling: EdgeHandling,
483) -> Result<[[f64; 3]; 3]> {
484    let width = dem.width();
485    let height = dem.height();
486    let mut z = [[0.0f64; 3]; 3];
487
488    for dy in 0..3i64 {
489        for dx in 0..3i64 {
490            let src_x = x as i64 + dx - 1;
491            let src_y = y as i64 + dy - 1;
492
493            let (px, py) = resolve_coords(src_x, src_y, width, height, edge_handling);
494
495            z[dy as usize][dx as usize] = dem.get_pixel(px, py).map_err(AlgorithmError::Core)?;
496        }
497    }
498
499    Ok(z)
500}
501
502/// Resolves coordinates that may be out of bounds based on the edge handling strategy
503fn resolve_coords(
504    x: i64,
505    y: i64,
506    width: u64,
507    height: u64,
508    edge_handling: EdgeHandling,
509) -> (u64, u64) {
510    let w = width as i64;
511    let h = height as i64;
512
513    match edge_handling {
514        EdgeHandling::Skip => {
515            // Clamp to valid range (should only be called for interior pixels anyway)
516            (x.clamp(0, w - 1) as u64, y.clamp(0, h - 1) as u64)
517        }
518        EdgeHandling::Reflect => {
519            let rx = if x < 0 {
520                (-x).min(w - 1)
521            } else if x >= w {
522                (2 * w - x - 2).max(0)
523            } else {
524                x
525            };
526            let ry = if y < 0 {
527                (-y).min(h - 1)
528            } else if y >= h {
529                (2 * h - y - 2).max(0)
530            } else {
531                y
532            };
533            (rx as u64, ry as u64)
534        }
535        EdgeHandling::Extrapolate => {
536            // Use nearest interior pixel (linear extrapolation approximated by clamping)
537            // True extrapolation would need more context; we use replicate here as fallback
538            (x.clamp(0, w - 1) as u64, y.clamp(0, h - 1) as u64)
539        }
540        EdgeHandling::Replicate => (x.clamp(0, w - 1) as u64, y.clamp(0, h - 1) as u64),
541    }
542}
543
544// ===========================================================================
545// Tests
546// ===========================================================================
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    fn create_slope_dem() -> RasterBuffer {
553        let mut dem = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
554        for y in 0..10 {
555            for x in 0..10 {
556                let _ = dem.set_pixel(x, y, (x + y) as f64);
557            }
558        }
559        dem
560    }
561
562    fn create_flat_dem() -> RasterBuffer {
563        let mut dem = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
564        for y in 0..10 {
565            for x in 0..10 {
566                let _ = dem.set_pixel(x, y, 100.0);
567            }
568        }
569        dem
570    }
571
572    fn create_north_facing_dem() -> RasterBuffer {
573        // Elevation decreases going north (y=0), increases going south
574        let mut dem = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
575        for y in 0..10 {
576            for x in 0..10 {
577                let _ = dem.set_pixel(x, y, y as f64 * 10.0);
578            }
579        }
580        dem
581    }
582
583    // --- Basic slope/aspect ---
584
585    #[test]
586    fn test_slope_aspect_horn() {
587        let dem = create_slope_dem();
588        let result = compute_slope_aspect(&dem, 1.0, 1.0);
589        assert!(result.is_ok());
590        let output = result.expect("slope/aspect");
591        let s = output.slope.get_pixel(5, 5).expect("slope pixel");
592        assert!(s > 0.0, "Slope should be positive on inclined surface");
593    }
594
595    #[test]
596    fn test_slope_flat() {
597        let dem = create_flat_dem();
598        let result = compute_slope_aspect(&dem, 1.0, 1.0);
599        assert!(result.is_ok());
600        let output = result.expect("slope/aspect");
601        let s = output.slope.get_pixel(5, 5).expect("slope pixel");
602        assert!(
603            s.abs() < 1e-6,
604            "Slope should be zero on flat terrain, got {s}"
605        );
606    }
607
608    #[test]
609    fn test_aspect_flat() {
610        let dem = create_flat_dem();
611        let result = compute_slope_aspect_advanced(
612            &dem,
613            1.0,
614            &SlopeAspectConfig {
615                z_factor: 1.0,
616                ..Default::default()
617            },
618        );
619        assert!(result.is_ok());
620        let output = result.expect("slope/aspect");
621        let a = output.aspect.get_pixel(5, 5).expect("aspect pixel");
622        // Flat area should have aspect = -1 (undefined)
623        assert!(a < 0.0, "Flat aspect should be -1, got {a}");
624    }
625
626    // --- Algorithm variants ---
627
628    #[test]
629    fn test_zevenbergen_thorne() {
630        let dem = create_slope_dem();
631        let config = SlopeAspectConfig {
632            algorithm: SlopeAlgorithm::ZevenbergenThorne,
633            ..Default::default()
634        };
635        let result = compute_slope_aspect_advanced(&dem, 1.0, &config);
636        assert!(result.is_ok());
637        let output = result.expect("zt");
638        let s = output.slope.get_pixel(5, 5).expect("slope");
639        assert!(s > 0.0);
640    }
641
642    #[test]
643    fn test_evans_young() {
644        let dem = create_slope_dem();
645        let config = SlopeAspectConfig {
646            algorithm: SlopeAlgorithm::EvansYoung,
647            ..Default::default()
648        };
649        let result = compute_slope_aspect_advanced(&dem, 1.0, &config);
650        assert!(result.is_ok());
651        let output = result.expect("ey");
652        let s = output.slope.get_pixel(5, 5).expect("slope");
653        assert!(s > 0.0);
654    }
655
656    #[test]
657    fn test_maximum_downhill() {
658        let dem = create_slope_dem();
659        let config = SlopeAspectConfig {
660            algorithm: SlopeAlgorithm::MaximumDownhill,
661            ..Default::default()
662        };
663        let result = compute_slope_aspect_advanced(&dem, 1.0, &config);
664        assert!(result.is_ok());
665        let output = result.expect("mds");
666        let s = output.slope.get_pixel(5, 5).expect("slope");
667        assert!(s > 0.0);
668    }
669
670    // --- Algorithm consistency ---
671
672    #[test]
673    fn test_algorithms_consistent_direction() {
674        let dem = create_north_facing_dem();
675
676        let horn = compute_slope_aspect_advanced(
677            &dem,
678            1.0,
679            &SlopeAspectConfig {
680                algorithm: SlopeAlgorithm::Horn,
681                ..Default::default()
682            },
683        )
684        .expect("horn");
685
686        let zt = compute_slope_aspect_advanced(
687            &dem,
688            1.0,
689            &SlopeAspectConfig {
690                algorithm: SlopeAlgorithm::ZevenbergenThorne,
691                ..Default::default()
692            },
693        )
694        .expect("zt");
695
696        let horn_aspect = horn.aspect.get_pixel(5, 5).expect("aspect");
697        let zt_aspect = zt.aspect.get_pixel(5, 5).expect("aspect");
698
699        // Both should agree on the general direction (south-facing: aspect ~180)
700        let diff = (horn_aspect - zt_aspect).abs();
701        assert!(
702            !(45.0..=315.0).contains(&diff),
703            "Horn and ZT aspects should be similar: {horn_aspect} vs {zt_aspect}"
704        );
705    }
706
707    // --- Unit conversion ---
708
709    #[test]
710    fn test_slope_units_degrees() {
711        let dem = create_slope_dem();
712        let config = SlopeAspectConfig {
713            slope_units: SlopeUnits::Degrees,
714            ..Default::default()
715        };
716        let result = compute_slope_aspect_advanced(&dem, 1.0, &config).expect("degrees");
717        let s = result.slope.get_pixel(5, 5).expect("slope");
718        assert!(s > 0.0 && s < 90.0, "Degrees should be in (0,90), got {s}");
719    }
720
721    #[test]
722    fn test_slope_units_radians() {
723        let dem = create_slope_dem();
724        let config = SlopeAspectConfig {
725            slope_units: SlopeUnits::Radians,
726            ..Default::default()
727        };
728        let result = compute_slope_aspect_advanced(&dem, 1.0, &config).expect("radians");
729        let s = result.slope.get_pixel(5, 5).expect("slope");
730        assert!(
731            s > 0.0 && s < PI / 2.0,
732            "Radians should be in (0, pi/2), got {s}"
733        );
734    }
735
736    #[test]
737    fn test_slope_units_percent() {
738        let dem = create_slope_dem();
739        let config = SlopeAspectConfig {
740            slope_units: SlopeUnits::Percent,
741            ..Default::default()
742        };
743        let result = compute_slope_aspect_advanced(&dem, 1.0, &config).expect("percent");
744        let s = result.slope.get_pixel(5, 5).expect("slope");
745        assert!(s > 0.0, "Percent slope should be positive");
746    }
747
748    #[test]
749    fn test_slope_units_tangent() {
750        let dem = create_slope_dem();
751        let config = SlopeAspectConfig {
752            slope_units: SlopeUnits::Tangent,
753            ..Default::default()
754        };
755        let result = compute_slope_aspect_advanced(&dem, 1.0, &config).expect("tangent");
756        let s = result.slope.get_pixel(5, 5).expect("slope");
757        assert!(s > 0.0, "Tangent slope should be positive");
758    }
759
760    #[test]
761    fn test_convert_slope_degrees() {
762        let deg = 45.0;
763        let rad = convert_slope_degrees(deg, SlopeUnits::Radians);
764        assert!((rad - PI / 4.0).abs() < 1e-10);
765
766        let pct = convert_slope_degrees(deg, SlopeUnits::Percent);
767        assert!((pct - 100.0).abs() < 0.1);
768
769        let same = convert_slope_degrees(deg, SlopeUnits::Degrees);
770        assert!((same - deg).abs() < 1e-10);
771    }
772
773    // --- Edge handling ---
774
775    #[test]
776    fn test_edge_handling_skip() {
777        let dem = create_slope_dem();
778        let config = SlopeAspectConfig {
779            edge_handling: EdgeHandling::Skip,
780            ..Default::default()
781        };
782        let result = compute_slope_aspect_advanced(&dem, 1.0, &config);
783        assert!(result.is_ok());
784        // Edge pixels should remain as zero
785        let output = result.expect("skip");
786        let edge = output.slope.get_pixel(0, 0).expect("edge pixel");
787        assert!(
788            edge.abs() < 1e-10,
789            "Skip edge pixel should be 0, got {edge}"
790        );
791    }
792
793    #[test]
794    fn test_edge_handling_reflect() {
795        let dem = create_slope_dem();
796        let config = SlopeAspectConfig {
797            edge_handling: EdgeHandling::Reflect,
798            ..Default::default()
799        };
800        let result = compute_slope_aspect_advanced(&dem, 1.0, &config);
801        assert!(result.is_ok());
802        // Corner pixel should have a non-zero slope with reflect handling
803        let output = result.expect("reflect");
804        let corner = output.slope.get_pixel(0, 0).expect("corner");
805        // With reflect, the corner gets processed
806        assert!(corner >= 0.0);
807    }
808
809    #[test]
810    fn test_edge_handling_replicate() {
811        let dem = create_slope_dem();
812        let config = SlopeAspectConfig {
813            edge_handling: EdgeHandling::Replicate,
814            ..Default::default()
815        };
816        let result = compute_slope_aspect_advanced(&dem, 1.0, &config);
817        assert!(result.is_ok());
818    }
819
820    // --- Convenience functions ---
821
822    #[test]
823    fn test_slope_convenience() {
824        let dem = create_slope_dem();
825        let result = slope(&dem, 1.0, 1.0);
826        assert!(result.is_ok());
827    }
828
829    #[test]
830    fn test_aspect_convenience() {
831        let dem = create_slope_dem();
832        let result = aspect(&dem, 1.0, 1.0);
833        assert!(result.is_ok());
834    }
835
836    #[test]
837    fn test_slope_advanced_convenience() {
838        let dem = create_slope_dem();
839        let config = SlopeAspectConfig {
840            algorithm: SlopeAlgorithm::ZevenbergenThorne,
841            slope_units: SlopeUnits::Percent,
842            ..Default::default()
843        };
844        let result = slope_advanced(&dem, 1.0, &config);
845        assert!(result.is_ok());
846    }
847
848    // --- Error cases ---
849
850    #[test]
851    fn test_too_small_dem() {
852        let dem = RasterBuffer::zeros(2, 2, RasterDataType::Float32);
853        let result = compute_slope_aspect(&dem, 1.0, 1.0);
854        assert!(result.is_err());
855    }
856
857    // --- Z-factor ---
858
859    #[test]
860    fn test_z_factor_scaling() {
861        let dem = create_slope_dem();
862        let result1 = compute_slope_aspect(&dem, 1.0, 1.0).expect("z=1");
863        let result2 = compute_slope_aspect(&dem, 1.0, 2.0).expect("z=2");
864
865        let s1 = result1.slope.get_pixel(5, 5).expect("s1");
866        let s2 = result2.slope.get_pixel(5, 5).expect("s2");
867
868        assert!(
869            s2 > s1,
870            "Higher z-factor should produce steeper slope: {s1} vs {s2}"
871        );
872    }
873}