Skip to main content

oxiphysics_geometry/
terrain_processing.rs

1#![allow(clippy::needless_range_loop)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Terrain processing and analysis algorithms.
6//!
7//! This module provides a comprehensive suite of Digital Elevation Model (DEM)
8//! analysis tools, including:
9//!
10//! - Slope and aspect computation (Horn's method)
11//! - Curvature analysis: profile, planform, and tangential curvature
12//! - Flow direction algorithms: D8 and D-infinity (Tarboton 1997)
13//! - Flow accumulation and watershed delineation
14//! - Stream network extraction
15//! - Terrain Roughness Index (TRI — Riley et al. 1999)
16//! - Terrain Position Index (TPI — Weiss 2001)
17//! - Topographic Wetness Index (TWI — Beven & Kirkby 1979)
18//! - Viewshed analysis (line-of-sight)
19//! - Slope stability index (infinite slope model)
20//! - Solar irradiance on terrain (incidence angle, terrain shading)
21
22#![allow(dead_code)]
23#![allow(clippy::too_many_arguments)]
24
25use std::f64::consts::PI;
26
27// ---------------------------------------------------------------------------
28// Section 1 — Core DEM structure
29// ---------------------------------------------------------------------------
30
31/// A regular-grid Digital Elevation Model.
32///
33/// Heights are stored in row-major order: index = `row * cols + col`.
34/// The origin is at the north-west corner; rows increase southward (y+),
35/// columns increase eastward (x+).
36#[derive(Debug, Clone)]
37pub struct Dem {
38    /// Elevation values (metres), row-major.
39    pub heights: Vec<f64>,
40    /// Number of rows.
41    pub rows: usize,
42    /// Number of columns.
43    pub cols: usize,
44    /// Cell size in metres (square cells assumed).
45    pub cell_size: f64,
46    /// No-data sentinel value.
47    pub nodata: f64,
48}
49
50impl Dem {
51    /// Construct a new DEM.
52    ///
53    /// # Panics
54    /// Panics if `heights.len() != rows * cols`.
55    pub fn new(heights: Vec<f64>, rows: usize, cols: usize, cell_size: f64) -> Self {
56        assert_eq!(heights.len(), rows * cols, "heights length mismatch");
57        Self {
58            heights,
59            rows,
60            cols,
61            cell_size,
62            nodata: -9999.0,
63        }
64    }
65
66    /// Return the elevation at `(row, col)`, or `None` if out of bounds.
67    pub fn get(&self, row: usize, col: usize) -> Option<f64> {
68        if row < self.rows && col < self.cols {
69            Some(self.heights[row * self.cols + col])
70        } else {
71            None
72        }
73    }
74
75    /// Return the elevation at `(row, col)` clamped to grid boundaries.
76    pub fn get_clamped(&self, row: i64, col: i64) -> f64 {
77        let r = row.clamp(0, self.rows as i64 - 1) as usize;
78        let c = col.clamp(0, self.cols as i64 - 1) as usize;
79        self.heights[r * self.cols + c]
80    }
81
82    /// Return true if the cell contains valid data.
83    pub fn is_valid(&self, row: usize, col: usize) -> bool {
84        if let Some(h) = self.get(row, col) {
85            (h - self.nodata).abs() > 1.0
86        } else {
87            false
88        }
89    }
90
91    /// Return a 3×3 neighbourhood window centred at `(row, col)`.
92    ///
93    /// Missing edge cells are filled by replication of the nearest valid cell.
94    pub fn window_3x3(&self, row: usize, col: usize) -> [[f64; 3]; 3] {
95        let mut w = [[0.0f64; 3]; 3];
96        for dr in 0i64..3 {
97            for dc in 0i64..3 {
98                let r = row as i64 + dr - 1;
99                let c = col as i64 + dc - 1;
100                w[dr as usize][dc as usize] = self.get_clamped(r, c);
101            }
102        }
103        w
104    }
105}
106
107// ---------------------------------------------------------------------------
108// Section 2 — Slope and aspect
109// ---------------------------------------------------------------------------
110
111/// Terrain slope in radians at every grid cell.
112///
113/// Uses Horn's (1981) finite-difference algorithm on the 3×3 neighbourhood.
114/// Returns a `Vec`f64` of length `rows * cols`.
115pub fn compute_slope(dem: &Dem) -> Vec<f64> {
116    let mut slope = vec![0.0f64; dem.rows * dem.cols];
117    let cs = dem.cell_size;
118    for r in 0..dem.rows {
119        for c in 0..dem.cols {
120            let w = dem.window_3x3(r, c);
121            // dz/dx  (west–east gradient)
122            let dzdx = ((w[0][2] + 2.0 * w[1][2] + w[2][2]) - (w[0][0] + 2.0 * w[1][0] + w[2][0]))
123                / (8.0 * cs);
124            // dz/dy  (north–south gradient)
125            let dzdy = ((w[2][0] + 2.0 * w[2][1] + w[2][2]) - (w[0][0] + 2.0 * w[0][1] + w[0][2]))
126                / (8.0 * cs);
127            slope[r * dem.cols + c] = (dzdx * dzdx + dzdy * dzdy).sqrt().atan();
128        }
129    }
130    slope
131}
132
133/// Terrain aspect in radians at every grid cell.
134///
135/// Aspect is measured clockwise from north (0 = north, π/2 = east).
136/// Flat areas (slope ≈ 0) are assigned aspect = -1.
137pub fn compute_aspect(dem: &Dem) -> Vec<f64> {
138    let mut aspect = vec![-1.0f64; dem.rows * dem.cols];
139    let cs = dem.cell_size;
140    for r in 0..dem.rows {
141        for c in 0..dem.cols {
142            let w = dem.window_3x3(r, c);
143            let dzdx = ((w[0][2] + 2.0 * w[1][2] + w[2][2]) - (w[0][0] + 2.0 * w[1][0] + w[2][0]))
144                / (8.0 * cs);
145            let dzdy = ((w[2][0] + 2.0 * w[2][1] + w[2][2]) - (w[0][0] + 2.0 * w[0][1] + w[0][2]))
146                / (8.0 * cs);
147            if dzdx.abs() > 1e-15 || dzdy.abs() > 1e-15 {
148                // atan2 measured from north, clockwise
149                let a = 180.0 - dzdy.atan2(dzdx).to_degrees() + 90.0;
150                let a = if a < 0.0 {
151                    a + 360.0
152                } else if a >= 360.0 {
153                    a - 360.0
154                } else {
155                    a
156                };
157                aspect[r * dem.cols + c] = a.to_radians();
158            }
159        }
160    }
161    aspect
162}
163
164// ---------------------------------------------------------------------------
165// Section 3 — Curvature
166// ---------------------------------------------------------------------------
167
168/// Curvature type selector.
169#[derive(Debug, Clone, Copy, PartialEq)]
170pub enum CurvatureKind {
171    /// Profile curvature: curvature in the direction of steepest descent.
172    Profile,
173    /// Planform (contour) curvature: curvature perpendicular to slope.
174    Planform,
175    /// Tangential curvature: horizontal curvature normal to the slope direction.
176    Tangential,
177}
178
179/// Compute terrain curvature (units: 1/m) for every grid cell.
180///
181/// Uses the second-order finite difference formulation of Zevenbergen & Thorne (1987).
182pub fn compute_curvature(dem: &Dem, kind: CurvatureKind) -> Vec<f64> {
183    let mut curv = vec![0.0f64; dem.rows * dem.cols];
184    let l = dem.cell_size;
185    for r in 0..dem.rows {
186        for c in 0..dem.cols {
187            let w = dem.window_3x3(r, c);
188            // Z layout (Zevenbergen & Thorne notation):
189            //  w[0][0]=Z1  w[0][1]=Z2  w[0][2]=Z3
190            //  w[1][0]=Z4  w[1][1]=Z5  w[1][2]=Z6
191            //  w[2][0]=Z7  w[2][1]=Z8  w[2][2]=Z9
192            let z5 = w[1][1];
193            let _ = z5; // centre (not needed directly)
194
195            let d = ((w[0][1] + w[2][1]) / 2.0 - w[1][1]) / (l * l);
196            let e = ((w[1][0] + w[1][2]) / 2.0 - w[1][1]) / (l * l);
197            let f = (-w[0][0] + w[0][2] + w[2][0] - w[2][2]) / (4.0 * l * l);
198            let g = (-w[0][1] + w[2][1]) / (2.0 * l);
199            let h = (w[1][2] - w[1][0]) / (2.0 * l);
200
201            let p2 = g * g + h * h;
202
203            curv[r * dem.cols + c] = match kind {
204                CurvatureKind::Profile => {
205                    if p2 > 1e-15 {
206                        -2.0 * (d * g * g + e * h * h + f * g * h) / (p2 * (1.0 + p2).sqrt())
207                    } else {
208                        0.0
209                    }
210                }
211                CurvatureKind::Planform => {
212                    if p2 > 1e-15 {
213                        -2.0 * (d * h * h + e * g * g - f * g * h) / p2.powf(1.5)
214                    } else {
215                        0.0
216                    }
217                }
218                CurvatureKind::Tangential => {
219                    if p2 > 1e-15 {
220                        -2.0 * (d * h * h + e * g * g - f * g * h) / (p2 * (1.0 + p2).sqrt())
221                    } else {
222                        0.0
223                    }
224                }
225            };
226        }
227    }
228    curv
229}
230
231// ---------------------------------------------------------------------------
232// Section 4 — Flow direction
233// ---------------------------------------------------------------------------
234
235/// D8 flow direction codes.
236///
237/// Directions are encoded as 1, 2, 4, 8, 16, 32, 64, 128 (clockwise from east,
238/// following the ESRI convention).
239pub type D8Code = u8;
240
241/// Neighbour offsets and D8 codes for the eight cardinal/diagonal directions.
242///
243/// Order: E, SE, S, SW, W, NW, N, NE.
244pub const D8_OFFSETS: [(i64, i64, D8Code); 8] = [
245    (0, 1, 1),
246    (1, 1, 2),
247    (1, 0, 4),
248    (1, -1, 8),
249    (0, -1, 16),
250    (-1, -1, 32),
251    (-1, 0, 64),
252    (-1, 1, 128),
253];
254
255/// Diagonal distance multiplier for D8 flow direction.
256///
257/// Cardinal neighbours have distance 1, diagonals have √2.
258fn d8_distance(dr: i64, dc: i64) -> f64 {
259    if dr != 0 && dc != 0 {
260        2.0f64.sqrt()
261    } else {
262        1.0
263    }
264}
265
266/// Compute D8 flow direction for every grid cell.
267///
268/// Returns `Vec`D8Code` with length `rows * cols`.  Flat or edge cells that
269/// have no steeper neighbour receive code 0.
270pub fn flow_direction_d8(dem: &Dem) -> Vec<D8Code> {
271    let mut fdir = vec![0u8; dem.rows * dem.cols];
272    for r in 0..dem.rows {
273        for c in 0..dem.cols {
274            let z = dem.heights[r * dem.cols + c];
275            let mut max_drop = 0.0f64;
276            let mut best_code = 0u8;
277            for &(dr, dc, code) in &D8_OFFSETS {
278                let nr = r as i64 + dr;
279                let nc = c as i64 + dc;
280                if nr < 0 || nr >= dem.rows as i64 || nc < 0 || nc >= dem.cols as i64 {
281                    continue;
282                }
283                let nz = dem.heights[nr as usize * dem.cols + nc as usize];
284                let dist = d8_distance(dr, dc) * dem.cell_size;
285                let drop = (z - nz) / dist;
286                if drop > max_drop {
287                    max_drop = drop;
288                    best_code = code;
289                }
290            }
291            fdir[r * dem.cols + c] = best_code;
292        }
293    }
294    fdir
295}
296
297/// D-infinity flow direction (Tarboton 1997).
298///
299/// Returns a `Vec`f64` of flow angles (radians, 0 = east, anticlockwise) for
300/// each cell.  The angle encodes which of the eight triangular facets receives
301/// flow, and how it is partitioned between the two bounding neighbours.
302pub fn flow_direction_dinf(dem: &Dem) -> Vec<f64> {
303    // Eight triangular facets: each defined by two neighbouring cells.
304    // Facet ordering: anti-clockwise from east.
305    // (dr1,dc1) is the "cardinal" neighbour; (dr2,dc2) is the diagonal.
306    const FACETS: [(i64, i64, i64, i64); 8] = [
307        (0, 1, -1, 1),
308        (-1, 1, -1, 0),
309        (-1, 0, -1, -1),
310        (-1, -1, 0, -1),
311        (0, -1, 1, -1),
312        (1, -1, 1, 0),
313        (1, 0, 1, 1),
314        (1, 1, 0, 1),
315    ];
316    let mut fdir = vec![0.0f64; dem.rows * dem.cols];
317    for r in 0..dem.rows {
318        for c in 0..dem.cols {
319            let z0 = dem.heights[r * dem.cols + c];
320            let l = dem.cell_size;
321            let mut max_slope = f64::NEG_INFINITY;
322            let mut best_angle = 0.0f64;
323            for (fi, &(dr1, dc1, dr2, dc2)) in FACETS.iter().enumerate() {
324                let facet_angle_base = fi as f64 * PI / 4.0;
325                let r1 = r as i64 + dr1;
326                let c1 = c as i64 + dc1;
327                let r2 = r as i64 + dr2;
328                let c2 = c as i64 + dc2;
329                if r1 < 0 || r1 >= dem.rows as i64 || c1 < 0 || c1 >= dem.cols as i64 {
330                    continue;
331                }
332                if r2 < 0 || r2 >= dem.rows as i64 || c2 < 0 || c2 >= dem.cols as i64 {
333                    continue;
334                }
335                let z1 = dem.heights[r1 as usize * dem.cols + c1 as usize];
336                let z2 = dem.heights[r2 as usize * dem.cols + c2 as usize];
337                let e1 = (z0 - z1) / l;
338                let e2 = (z1 - z2) / l;
339                // Optimal angle within facet [0, π/4]
340                let alpha = if e2.abs() < 1e-15 {
341                    0.0
342                } else {
343                    (e1 / e2).atan().clamp(0.0, PI / 4.0)
344                };
345                let s = (e1 * e1 + e2 * e2 - 2.0 * alpha.cos() * e1 * e2).sqrt();
346                if s > max_slope {
347                    max_slope = s;
348                    best_angle = facet_angle_base + alpha;
349                }
350            }
351            fdir[r * dem.cols + c] = best_angle;
352        }
353    }
354    fdir
355}
356
357// ---------------------------------------------------------------------------
358// Section 5 — Flow accumulation
359// ---------------------------------------------------------------------------
360
361/// Compute D8 flow accumulation (number of upstream cells draining into each cell).
362///
363/// Uses a topological sort (cells processed from high to low elevation).
364pub fn flow_accumulation_d8(dem: &Dem, fdir: &[D8Code]) -> Vec<u32> {
365    let n = dem.rows * dem.cols;
366    let mut accum = vec![1u32; n];
367    // Sort cell indices by elevation descending
368    let mut order: Vec<usize> = (0..n).collect();
369    order.sort_by(|&a, &b| {
370        dem.heights[b]
371            .partial_cmp(&dem.heights[a])
372            .unwrap_or(std::cmp::Ordering::Equal)
373    });
374    for idx in order {
375        let r = idx / dem.cols;
376        let c = idx % dem.cols;
377        let code = fdir[idx];
378        if code == 0 {
379            continue;
380        }
381        // Decode D8 code to neighbour offset
382        for &(dr, dc, mask) in &D8_OFFSETS {
383            if code == mask {
384                let nr = r as i64 + dr;
385                let nc = c as i64 + dc;
386                if nr >= 0 && nr < dem.rows as i64 && nc >= 0 && nc < dem.cols as i64 {
387                    let nidx = nr as usize * dem.cols + nc as usize;
388                    accum[nidx] += accum[idx];
389                }
390                break;
391            }
392        }
393    }
394    accum
395}
396
397// ---------------------------------------------------------------------------
398// Section 6 — Watershed delineation
399// ---------------------------------------------------------------------------
400
401/// Delineate the watershed (contributing area) upstream of an outlet cell.
402///
403/// Returns a boolean mask of the same size as the DEM.
404pub fn delineate_watershed(
405    dem: &Dem,
406    fdir: &[D8Code],
407    outlet_row: usize,
408    outlet_col: usize,
409) -> Vec<bool> {
410    let n = dem.rows * dem.cols;
411    let mut mask = vec![false; n];
412    // Build reverse flow graph
413    let mut upstream: Vec<Vec<usize>> = vec![vec![]; n];
414    for r in 0..dem.rows {
415        for c in 0..dem.cols {
416            let idx = r * dem.cols + c;
417            let code = fdir[idx];
418            if code == 0 {
419                continue;
420            }
421            for &(dr, dc, mask_code) in &D8_OFFSETS {
422                if code == mask_code {
423                    let nr = r as i64 + dr;
424                    let nc = c as i64 + dc;
425                    if nr >= 0 && nr < dem.rows as i64 && nc >= 0 && nc < dem.cols as i64 {
426                        let nidx = nr as usize * dem.cols + nc as usize;
427                        upstream[nidx].push(idx);
428                    }
429                    break;
430                }
431            }
432        }
433    }
434    // BFS from outlet
435    let outlet = outlet_row * dem.cols + outlet_col;
436    let mut queue = std::collections::VecDeque::new();
437    queue.push_back(outlet);
438    mask[outlet] = true;
439    while let Some(idx) = queue.pop_front() {
440        for &up in &upstream[idx] {
441            if !mask[up] {
442                mask[up] = true;
443                queue.push_back(up);
444            }
445        }
446    }
447    mask
448}
449
450// ---------------------------------------------------------------------------
451// Section 7 — Stream network extraction
452// ---------------------------------------------------------------------------
453
454/// Extract stream network by thresholding flow accumulation.
455///
456/// Cells with accumulation ≥ `threshold` are classified as streams.
457/// Returns a boolean mask.
458pub fn extract_streams(accum: &[u32], threshold: u32) -> Vec<bool> {
459    accum.iter().map(|&a| a >= threshold).collect()
460}
461
462/// Strahler stream order for every stream cell.
463///
464/// Returns a `Vec`u8` where 0 indicates non-stream cells.
465/// Order assignment follows standard Strahler rules.
466pub fn strahler_order(dem: &Dem, fdir: &[D8Code], streams: &[bool]) -> Vec<u8> {
467    let n = dem.rows * dem.cols;
468    let mut order = vec![0u8; n];
469    // Build upstream adjacency restricted to stream cells
470    let mut upstream: Vec<Vec<usize>> = vec![vec![]; n];
471    for r in 0..dem.rows {
472        for c in 0..dem.cols {
473            let idx = r * dem.cols + c;
474            if !streams[idx] {
475                continue;
476            }
477            let code = fdir[idx];
478            if code == 0 {
479                continue;
480            }
481            for &(dr, dc, mask_code) in &D8_OFFSETS {
482                if code == mask_code {
483                    let nr = r as i64 + dr;
484                    let nc = c as i64 + dc;
485                    if nr >= 0 && nr < dem.rows as i64 && nc >= 0 && nc < dem.cols as i64 {
486                        let nidx = nr as usize * dem.cols + nc as usize;
487                        if streams[nidx] {
488                            upstream[nidx].push(idx);
489                        }
490                    }
491                    break;
492                }
493            }
494        }
495    }
496    // Process in topological order (high → low elevation)
497    let mut topo_order: Vec<usize> = (0..n).filter(|&i| streams[i]).collect();
498    topo_order.sort_by(|&a, &b| {
499        dem.heights[b]
500            .partial_cmp(&dem.heights[a])
501            .unwrap_or(std::cmp::Ordering::Equal)
502    });
503    for idx in topo_order {
504        let ups: Vec<u8> = upstream[idx].iter().map(|&u| order[u]).collect();
505        if ups.is_empty() {
506            order[idx] = 1;
507        } else {
508            let max_ord = *ups.iter().max().expect("iterator should not be empty");
509            let count_max = ups.iter().filter(|&&o| o == max_ord).count();
510            order[idx] = if count_max >= 2 { max_ord + 1 } else { max_ord };
511        }
512    }
513    order
514}
515
516// ---------------------------------------------------------------------------
517// Section 8 — Terrain Roughness Index (TRI)
518// ---------------------------------------------------------------------------
519
520/// Terrain Roughness Index (Riley et al. 1999).
521///
522/// Defined as the square root of the sum of squared elevation differences
523/// between the centre cell and its eight neighbours.
524pub fn terrain_roughness_index(dem: &Dem) -> Vec<f64> {
525    let mut tri = vec![0.0f64; dem.rows * dem.cols];
526    for r in 0..dem.rows {
527        for c in 0..dem.cols {
528            let z = dem.heights[r * dem.cols + c];
529            let w = dem.window_3x3(r, c);
530            let mut sum_sq = 0.0f64;
531            for dr in 0..3usize {
532                for dc in 0..3usize {
533                    if dr == 1 && dc == 1 {
534                        continue;
535                    }
536                    let diff = w[dr][dc] - z;
537                    sum_sq += diff * diff;
538                }
539            }
540            tri[r * dem.cols + c] = sum_sq.sqrt();
541        }
542    }
543    tri
544}
545
546// ---------------------------------------------------------------------------
547// Section 9 — Terrain Position Index (TPI)
548// ---------------------------------------------------------------------------
549
550/// Terrain Position Index (Weiss 2001).
551///
552/// TPI = elevation of cell − mean elevation of annular neighbourhood.
553/// `radius` is the search radius in cells.
554pub fn terrain_position_index(dem: &Dem, radius: usize) -> Vec<f64> {
555    let mut tpi = vec![0.0f64; dem.rows * dem.cols];
556    for r in 0..dem.rows {
557        for c in 0..dem.cols {
558            let z = dem.heights[r * dem.cols + c];
559            let mut sum = 0.0f64;
560            let mut count = 0usize;
561            for dr in -(radius as i64)..=(radius as i64) {
562                for dc in -(radius as i64)..=(radius as i64) {
563                    if dr == 0 && dc == 0 {
564                        continue;
565                    }
566                    let dist_sq = (dr * dr + dc * dc) as f64;
567                    if dist_sq > (radius as f64 * radius as f64) {
568                        continue;
569                    }
570                    let nr = r as i64 + dr;
571                    let nc = c as i64 + dc;
572                    if nr >= 0 && nr < dem.rows as i64 && nc >= 0 && nc < dem.cols as i64 {
573                        sum += dem.heights[nr as usize * dem.cols + nc as usize];
574                        count += 1;
575                    }
576                }
577            }
578            tpi[r * dem.cols + c] = if count > 0 {
579                z - sum / count as f64
580            } else {
581                0.0
582            };
583        }
584    }
585    tpi
586}
587
588/// Landform classification based on TPI and slope.
589#[derive(Debug, Clone, Copy, PartialEq)]
590pub enum LandformClass {
591    /// Ridges and hilltops (high positive TPI).
592    Ridge,
593    /// Upper slopes.
594    UpperSlope,
595    /// Middle slopes (moderate TPI, moderate slope).
596    MiddleSlope,
597    /// Flat areas (low TPI, low slope).
598    Flat,
599    /// Lower slopes.
600    LowerSlope,
601    /// Valleys and depressions (high negative TPI).
602    Valley,
603}
604
605/// Classify landform type using a dual-scale TPI approach (Weiss 2001).
606pub fn classify_landform(tpi_small: f64, tpi_large: f64, slope_rad: f64) -> LandformClass {
607    let std_small = 1.0; // normalised
608    let std_large = 1.0;
609    let ts = tpi_small / std_small;
610    let tl = tpi_large / std_large;
611    let slope_deg = slope_rad.to_degrees();
612    if tl > 1.0 {
613        LandformClass::Ridge
614    } else if tl < -1.0 {
615        LandformClass::Valley
616    } else if ts > 1.0 {
617        LandformClass::UpperSlope
618    } else if ts < -1.0 {
619        LandformClass::LowerSlope
620    } else if slope_deg < 5.0 {
621        LandformClass::Flat
622    } else {
623        LandformClass::MiddleSlope
624    }
625}
626
627// ---------------------------------------------------------------------------
628// Section 10 — Topographic Wetness Index (TWI)
629// ---------------------------------------------------------------------------
630
631/// Topographic Wetness Index (Beven & Kirkby 1979).
632///
633/// TWI = ln(a / tan(β)), where *a* is the specific catchment area (m²/m) and
634/// *β* is the local slope angle (radians).
635pub fn topographic_wetness_index(dem: &Dem, accum: &[u32], slope: &[f64]) -> Vec<f64> {
636    let n = dem.rows * dem.cols;
637    let mut twi = vec![0.0f64; n];
638    let cs = dem.cell_size;
639    for i in 0..n {
640        let a = (accum[i] as f64) * cs; // specific catchment area
641        let beta = slope[i].max(1e-6_f64.atan()); // minimum slope to avoid division by zero
642        twi[i] = (a / beta.tan()).ln();
643    }
644    twi
645}
646
647// ---------------------------------------------------------------------------
648// Section 11 — Viewshed analysis
649// ---------------------------------------------------------------------------
650
651/// Observer specification for viewshed analysis.
652#[derive(Debug, Clone, Copy)]
653pub struct Observer {
654    /// Row index of the observer.
655    pub row: usize,
656    /// Column index of the observer.
657    pub col: usize,
658    /// Observer height above terrain surface (metres).
659    pub height_above_terrain: f64,
660    /// Maximum sight distance (metres; use `f64::MAX` for unlimited).
661    pub max_range: f64,
662}
663
664/// Compute binary viewshed for a single observer.
665///
666/// Uses the reference plane method: for each target cell the sight line is
667/// tested against all intermediate cells sampled along the Bresenham raster.
668/// Returns a boolean mask (true = visible).
669pub fn compute_viewshed(dem: &Dem, obs: &Observer) -> Vec<bool> {
670    let n = dem.rows * dem.cols;
671    let mut visible = vec![false; n];
672    let obs_z = dem.heights[obs.row * dem.cols + obs.col] + obs.height_above_terrain;
673
674    for r in 0..dem.rows {
675        for c in 0..dem.cols {
676            let dr = r as f64 - obs.row as f64;
677            let dc = c as f64 - obs.col as f64;
678            let dist = (dr * dr + dc * dc).sqrt() * dem.cell_size;
679            if dist > obs.max_range {
680                continue;
681            }
682            if r == obs.row && c == obs.col {
683                visible[r * dem.cols + c] = true;
684                continue;
685            }
686            // Bresenham line from observer to target
687            let mut blocked = false;
688            let steps = dr.abs().max(dc.abs()) as usize;
689            if steps == 0 {
690                visible[r * dem.cols + c] = true;
691                continue;
692            }
693            for step in 1..steps {
694                let t = step as f64 / steps as f64;
695                let ir = (obs.row as f64 + t * dr).round() as i64;
696                let ic = (obs.col as f64 + t * dc).round() as i64;
697                if ir < 0 || ir >= dem.rows as i64 || ic < 0 || ic >= dem.cols as i64 {
698                    continue;
699                }
700                let inter_z = dem.heights[ir as usize * dem.cols + ic as usize];
701                let inter_dist = (t * dist).max(1e-9);
702                let target_z = dem.heights[r * dem.cols + c];
703                let los_z = obs_z + (target_z - obs_z) * (inter_dist / dist.max(1e-9));
704                if inter_z > los_z {
705                    blocked = true;
706                    break;
707                }
708            }
709            visible[r * dem.cols + c] = !blocked;
710        }
711    }
712    visible
713}
714
715/// Cumulative viewshed: number of observers that can see each cell.
716pub fn cumulative_viewshed(dem: &Dem, observers: &[Observer]) -> Vec<u32> {
717    let n = dem.rows * dem.cols;
718    let mut count = vec![0u32; n];
719    for obs in observers {
720        let vs = compute_viewshed(dem, obs);
721        for i in 0..n {
722            if vs[i] {
723                count[i] += 1;
724            }
725        }
726    }
727    count
728}
729
730// ---------------------------------------------------------------------------
731// Section 12 — Slope stability index
732// ---------------------------------------------------------------------------
733
734/// Parameters for the infinite-slope stability model.
735#[derive(Debug, Clone, Copy)]
736pub struct SlopeStabilityParams {
737    /// Soil cohesion (Pa).
738    pub cohesion: f64,
739    /// Internal friction angle (radians).
740    pub friction_angle: f64,
741    /// Soil bulk density (kg/m³).
742    pub soil_density: f64,
743    /// Water density (kg/m³).
744    pub water_density: f64,
745    /// Depth of failure plane (m).
746    pub depth: f64,
747    /// Degree of saturation (0–1).
748    pub saturation: f64,
749}
750
751impl Default for SlopeStabilityParams {
752    fn default() -> Self {
753        Self {
754            cohesion: 5000.0,
755            friction_angle: 30.0_f64.to_radians(),
756            soil_density: 1800.0,
757            water_density: 1000.0,
758            depth: 1.0,
759            saturation: 0.5,
760        }
761    }
762}
763
764/// Compute the Factor of Safety (FS) for every grid cell.
765///
766/// Uses the infinite-slope model:
767/// FS = (c′ + (ρs − m·ρw)·g·h·cos²β·tan φ) / (ρs·g·h·sin β·cos β)
768///
769/// where *m* = saturation ratio, *β* = slope angle, *h* = depth.
770pub fn slope_stability_index(dem: &Dem, slope: &[f64], params: &SlopeStabilityParams) -> Vec<f64> {
771    let g = 9.81f64;
772    let n = dem.rows * dem.cols;
773    let mut fs = vec![0.0f64; n];
774    for i in 0..n {
775        let beta = slope[i];
776        let sin_b = beta.sin();
777        let cos_b = beta.cos();
778        let h = params.depth;
779        let m = params.saturation;
780        let rho_s = params.soil_density;
781        let rho_w = params.water_density;
782        let c = params.cohesion;
783        let phi = params.friction_angle;
784        let denom = rho_s * g * h * sin_b * cos_b;
785        if denom.abs() < 1e-9 {
786            fs[i] = f64::MAX;
787        } else {
788            let numer = c + (rho_s - m * rho_w) * g * h * cos_b * cos_b * phi.tan();
789            fs[i] = numer / denom;
790        }
791    }
792    fs
793}
794
795// ---------------------------------------------------------------------------
796// Section 13 — Solar irradiance on terrain
797// ---------------------------------------------------------------------------
798
799/// Solar position in the sky.
800#[derive(Debug, Clone, Copy)]
801pub struct SolarPosition {
802    /// Solar zenith angle (radians, 0 = directly overhead).
803    pub zenith: f64,
804    /// Solar azimuth (radians, 0 = north, clockwise).
805    pub azimuth: f64,
806}
807
808impl SolarPosition {
809    /// Construct from elevation and azimuth angles (both in degrees).
810    pub fn from_degrees(elevation_deg: f64, azimuth_deg: f64) -> Self {
811        Self {
812            zenith: (90.0 - elevation_deg).to_radians(),
813            azimuth: azimuth_deg.to_radians(),
814        }
815    }
816
817    /// Direct normal irradiance after atmospheric attenuation.
818    ///
819    /// Uses Beer-Lambert with air mass computed from zenith angle.
820    /// `i0` is the extraterrestrial irradiance (W/m²), `tau` is the optical depth.
821    pub fn direct_normal_irradiance(&self, i0: f64, tau: f64) -> f64 {
822        let cos_z = self.zenith.cos().max(0.0);
823        if cos_z < 1e-6 {
824            return 0.0;
825        }
826        let air_mass = 1.0 / cos_z;
827        i0 * (-tau * air_mass).exp()
828    }
829}
830
831/// Compute the cosine of incidence angle between the sun and the terrain surface.
832///
833/// `slope_rad` is terrain slope, `aspect_rad` is terrain aspect (clockwise from north).
834/// Returns the cosine of the incidence angle (clamped to ≥ 0).
835pub fn cos_incidence(solar: &SolarPosition, slope_rad: f64, aspect_rad: f64) -> f64 {
836    let cos_z = solar.zenith.cos();
837    let sin_z = solar.zenith.sin();
838    let cos_b = slope_rad.cos();
839    let sin_b = slope_rad.sin();
840    let delta_azimuth = solar.azimuth - aspect_rad;
841    let ci = cos_z * cos_b + sin_z * sin_b * delta_azimuth.cos();
842    ci.max(0.0)
843}
844
845/// Compute global horizontal irradiance (GHI) on every terrain cell.
846///
847/// Accounts for terrain slope, aspect, and self-shading (cast shadows are not
848/// computed here — use `compute_viewshed` for that).
849pub fn terrain_solar_irradiance(
850    dem: &Dem,
851    slope: &[f64],
852    aspect: &[f64],
853    solar: &SolarPosition,
854    dni: f64,
855    diffuse_horizontal: f64,
856) -> Vec<f64> {
857    let n = dem.rows * dem.cols;
858    let mut irr = vec![0.0f64; n];
859    let cos_z = solar.zenith.cos().max(0.0);
860    for i in 0..n {
861        let ci = cos_incidence(solar, slope[i], aspect[i].max(0.0));
862        // Direct component on tilted surface
863        let direct = if cos_z > 1e-6 { dni * ci } else { 0.0 };
864        // Diffuse component (isotropic sky model)
865        let diffuse = diffuse_horizontal * (1.0 + slope[i].cos()) / 2.0;
866        irr[i] = direct + diffuse;
867    }
868    irr
869}
870
871// ---------------------------------------------------------------------------
872// Section 14 — Terrain statistics helpers
873// ---------------------------------------------------------------------------
874
875/// Compute basic statistics of a raster field.
876///
877/// Returns `(min, max, mean, std_dev)`.
878pub fn raster_stats(data: &[f64]) -> (f64, f64, f64, f64) {
879    if data.is_empty() {
880        return (0.0, 0.0, 0.0, 0.0);
881    }
882    let mut min = f64::MAX;
883    let mut max = f64::MIN;
884    let mut sum = 0.0f64;
885    for &v in data {
886        if v < min {
887            min = v;
888        }
889        if v > max {
890            max = v;
891        }
892        sum += v;
893    }
894    let mean = sum / data.len() as f64;
895    let var = data.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / data.len() as f64;
896    (min, max, mean, var.sqrt())
897}
898
899/// Normalise a raster to the range \[0, 1\].
900pub fn normalise_raster(data: &[f64]) -> Vec<f64> {
901    let (min, max, _, _) = raster_stats(data);
902    let range = max - min;
903    if range.abs() < 1e-15 {
904        return vec![0.0; data.len()];
905    }
906    data.iter().map(|&v| (v - min) / range).collect()
907}
908
909/// Resample a DEM to a coarser grid using bilinear interpolation.
910///
911/// `factor` is the downsampling factor (e.g. 2 halves the resolution).
912pub fn resample_dem(dem: &Dem, factor: usize) -> Dem {
913    let new_rows = dem.rows.div_ceil(factor);
914    let new_cols = dem.cols.div_ceil(factor);
915    let mut heights = vec![0.0f64; new_rows * new_cols];
916    for nr in 0..new_rows {
917        for nc in 0..new_cols {
918            let r0 = (nr * factor) as i64;
919            let c0 = (nc * factor) as i64;
920            heights[nr * new_cols + nc] = dem.get_clamped(r0, c0);
921        }
922    }
923    Dem::new(heights, new_rows, new_cols, dem.cell_size * factor as f64)
924}
925
926/// Fill sinks in a DEM using a simple iterative raising algorithm.
927///
928/// Each iteration raises cells that are lower than all their neighbours.
929/// Converges in O(n) iterations for well-behaved terrain.
930pub fn fill_sinks(dem: &Dem, max_iterations: usize) -> Dem {
931    let mut filled = dem.clone();
932    for _iter in 0..max_iterations {
933        let mut changed = false;
934        for r in 0..filled.rows {
935            for c in 0..filled.cols {
936                let idx = r * filled.cols + c;
937                let z = filled.heights[idx];
938                let w = filled.window_3x3(r, c);
939                let min_nb = {
940                    let mut m = f64::MAX;
941                    for dr in 0..3usize {
942                        for dc in 0..3usize {
943                            if dr == 1 && dc == 1 {
944                                continue;
945                            }
946                            if w[dr][dc] < m {
947                                m = w[dr][dc];
948                            }
949                        }
950                    }
951                    m
952                };
953                if z < min_nb {
954                    filled.heights[idx] = min_nb;
955                    changed = true;
956                }
957            }
958        }
959        if !changed {
960            break;
961        }
962    }
963    filled
964}
965
966// ---------------------------------------------------------------------------
967// Section 15 — Contour line extraction (marching squares)
968// ---------------------------------------------------------------------------
969
970/// A single contour polyline at a given elevation.
971#[derive(Debug, Clone)]
972pub struct Contour {
973    /// Elevation of this contour (metres).
974    pub elevation: f64,
975    /// Vertices as (x, y) pairs in grid coordinates.
976    pub vertices: Vec<(f64, f64)>,
977}
978
979/// Extract contour lines at the specified elevation levels.
980///
981/// Uses the marching squares algorithm on the DEM grid.
982pub fn extract_contours(dem: &Dem, levels: &[f64]) -> Vec<Contour> {
983    let mut contours = Vec::new();
984    for &level in levels {
985        let vertices = marching_squares_level(dem, level);
986        if !vertices.is_empty() {
987            contours.push(Contour {
988                elevation: level,
989                vertices,
990            });
991        }
992    }
993    contours
994}
995
996fn marching_squares_level(dem: &Dem, level: f64) -> Vec<(f64, f64)> {
997    let mut pts = Vec::new();
998    for r in 0..dem.rows.saturating_sub(1) {
999        for c in 0..dem.cols.saturating_sub(1) {
1000            let z00 = dem.heights[r * dem.cols + c];
1001            let z10 = dem.heights[(r + 1) * dem.cols + c];
1002            let z01 = dem.heights[r * dem.cols + (c + 1)];
1003            let z11 = dem.heights[(r + 1) * dem.cols + (c + 1)];
1004            // Simple edge interpolation
1005            let interp = |za: f64, zb: f64| -> f64 {
1006                if (zb - za).abs() < 1e-15 {
1007                    0.5
1008                } else {
1009                    (level - za) / (zb - za)
1010                }
1011            };
1012            let mut code = 0u8;
1013            if z00 >= level {
1014                code |= 1;
1015            }
1016            if z01 >= level {
1017                code |= 2;
1018            }
1019            if z10 >= level {
1020                code |= 4;
1021            }
1022            if z11 >= level {
1023                code |= 8;
1024            }
1025            if code == 0 || code == 15 {
1026                continue;
1027            }
1028            // Top edge
1029            if (code & 3) == 1 || (code & 3) == 2 {
1030                let t = interp(z00, z01);
1031                pts.push((c as f64 + t, r as f64));
1032            }
1033            // Left edge
1034            if (code & 5) == 1 || (code & 5) == 4 {
1035                let t = interp(z00, z10);
1036                pts.push((c as f64, r as f64 + t));
1037            }
1038        }
1039    }
1040    pts
1041}
1042
1043// ---------------------------------------------------------------------------
1044// Section 16 — Hypsometric curve
1045// ---------------------------------------------------------------------------
1046
1047/// Compute the hypsometric curve for a DEM.
1048///
1049/// Returns a vector of `(relative_height, relative_area)` pairs where both
1050/// values are normalised to \[0, 1\].
1051pub fn hypsometric_curve(dem: &Dem, bins: usize) -> Vec<(f64, f64)> {
1052    let (min, max, _, _) = raster_stats(&dem.heights);
1053    let range = max - min;
1054    if range < 1e-9 {
1055        return vec![(0.0, 1.0), (1.0, 0.0)];
1056    }
1057    let total = dem.heights.len() as f64;
1058    let mut curve = Vec::with_capacity(bins + 1);
1059    for b in 0..=bins {
1060        let frac = b as f64 / bins as f64;
1061        let threshold = min + frac * range;
1062        let above = dem.heights.iter().filter(|&&h| h >= threshold).count() as f64;
1063        curve.push((frac, above / total));
1064    }
1065    curve
1066}
1067
1068/// Hypsometric integral — area under the hypsometric curve.
1069///
1070/// Values > 0.6 indicate youthful terrain; < 0.35 indicate monadnock stage.
1071pub fn hypsometric_integral(curve: &[(f64, f64)]) -> f64 {
1072    if curve.len() < 2 {
1073        return 0.0;
1074    }
1075    let mut area = 0.0f64;
1076    for w in curve.windows(2) {
1077        let dx = w[1].0 - w[0].0;
1078        let avg_y = (w[0].1 + w[1].1) / 2.0;
1079        area += dx * avg_y;
1080    }
1081    area
1082}
1083
1084// ---------------------------------------------------------------------------
1085// Section 17 — Ridge / valley line extraction
1086// ---------------------------------------------------------------------------
1087
1088/// Classify each cell as ridge, valley, or neither.
1089#[derive(Debug, Clone, Copy, PartialEq)]
1090pub enum RidgeValley {
1091    /// Local elevation maximum in at least one principal direction.
1092    Ridge,
1093    /// Local elevation minimum in at least one principal direction.
1094    Valley,
1095    /// Neither ridge nor valley.
1096    Slope,
1097}
1098
1099/// Simple ridge and valley detection based on profile curvature sign.
1100///
1101/// Positive profile curvature → concave (valley); negative → convex (ridge).
1102pub fn ridge_valley_lines(dem: &Dem) -> Vec<RidgeValley> {
1103    let curv = compute_curvature(dem, CurvatureKind::Profile);
1104    curv.iter()
1105        .map(|&c| {
1106            if c > 0.01 {
1107                RidgeValley::Valley
1108            } else if c < -0.01 {
1109                RidgeValley::Ridge
1110            } else {
1111                RidgeValley::Slope
1112            }
1113        })
1114        .collect()
1115}
1116
1117// ---------------------------------------------------------------------------
1118// Section 18 — Unit tests
1119// ---------------------------------------------------------------------------
1120
1121#[cfg(test)]
1122mod tests {
1123    use super::*;
1124
1125    /// Build a small synthetic DEM: a cone with peak at the centre.
1126    fn cone_dem(rows: usize, cols: usize, cell_size: f64) -> Dem {
1127        let cx = (cols as f64 - 1.0) / 2.0;
1128        let cy = (rows as f64 - 1.0) / 2.0;
1129        let heights: Vec<f64> = (0..rows)
1130            .flat_map(|r| {
1131                (0..cols).map(move |c| {
1132                    let dx = c as f64 - cx;
1133                    let dy = r as f64 - cy;
1134                    100.0 - (dx * dx + dy * dy).sqrt() * cell_size
1135                })
1136            })
1137            .collect();
1138        Dem::new(heights, rows, cols, cell_size)
1139    }
1140
1141    /// Build a flat DEM.
1142    fn flat_dem(rows: usize, cols: usize, elevation: f64) -> Dem {
1143        Dem::new(vec![elevation; rows * cols], rows, cols, 10.0)
1144    }
1145
1146    /// Build a simple tilted plane DEM (slope in x direction).
1147    fn tilted_dem(rows: usize, cols: usize, cell_size: f64, slope_gradient: f64) -> Dem {
1148        let heights: Vec<f64> = (0..rows)
1149            .flat_map(|_r| (0..cols).map(move |c| c as f64 * cell_size * slope_gradient))
1150            .collect();
1151        Dem::new(heights, rows, cols, cell_size)
1152    }
1153
1154    #[test]
1155    fn test_dem_construction() {
1156        let dem = flat_dem(5, 5, 100.0);
1157        assert_eq!(dem.rows, 5);
1158        assert_eq!(dem.cols, 5);
1159        assert_eq!(dem.heights.len(), 25);
1160    }
1161
1162    #[test]
1163    fn test_dem_get_valid() {
1164        let dem = flat_dem(4, 4, 50.0);
1165        assert_eq!(dem.get(2, 3), Some(50.0));
1166        assert_eq!(dem.get(4, 0), None);
1167    }
1168
1169    #[test]
1170    fn test_dem_get_clamped_edge() {
1171        let dem = flat_dem(3, 3, 42.0);
1172        assert_eq!(dem.get_clamped(-1, 0), 42.0);
1173        assert_eq!(dem.get_clamped(0, 10), 42.0);
1174    }
1175
1176    #[test]
1177    fn test_flat_dem_slope_zero() {
1178        let dem = flat_dem(5, 5, 100.0);
1179        let slope = compute_slope(&dem);
1180        for &s in &slope {
1181            assert!(s.abs() < 1e-10, "flat DEM slope should be zero, got {}", s);
1182        }
1183    }
1184
1185    #[test]
1186    fn test_tilted_dem_slope_nonzero() {
1187        let dem = tilted_dem(5, 5, 10.0, 0.5);
1188        let slope = compute_slope(&dem);
1189        // Interior cells should have nonzero slope
1190        let interior_slope = slope[2 * 5 + 2];
1191        assert!(
1192            interior_slope > 0.01,
1193            "tilted DEM should have positive slope, got {}",
1194            interior_slope
1195        );
1196    }
1197
1198    #[test]
1199    fn test_aspect_range() {
1200        let dem = cone_dem(7, 7, 10.0);
1201        let aspect = compute_aspect(&dem);
1202        for &a in &aspect {
1203            if a >= 0.0 {
1204                assert!(a <= 2.0 * PI + 1e-9, "aspect out of range: {}", a);
1205            }
1206        }
1207    }
1208
1209    #[test]
1210    fn test_profile_curvature_flat_near_zero() {
1211        let dem = flat_dem(5, 5, 200.0);
1212        let curv = compute_curvature(&dem, CurvatureKind::Profile);
1213        for &c in &curv {
1214            assert!(c.abs() < 1e-10, "flat curvature should be 0, got {}", c);
1215        }
1216    }
1217
1218    #[test]
1219    fn test_planform_curvature_flat_near_zero() {
1220        let dem = flat_dem(5, 5, 200.0);
1221        let curv = compute_curvature(&dem, CurvatureKind::Planform);
1222        for &c in &curv {
1223            assert!(
1224                c.abs() < 1e-10,
1225                "flat planform curvature should be 0, got {}",
1226                c
1227            );
1228        }
1229    }
1230
1231    #[test]
1232    fn test_tangential_curvature_flat_near_zero() {
1233        let dem = flat_dem(5, 5, 200.0);
1234        let curv = compute_curvature(&dem, CurvatureKind::Tangential);
1235        for &c in &curv {
1236            assert!(
1237                c.abs() < 1e-10,
1238                "flat tangential curvature should be 0, got {}",
1239                c
1240            );
1241        }
1242    }
1243
1244    #[test]
1245    fn test_flow_direction_d8_tilted() {
1246        let dem = tilted_dem(5, 5, 10.0, 1.0);
1247        let fdir = flow_direction_d8(&dem);
1248        // Interior cells should flow in the +x direction (code=1)
1249        let mid = 2 * 5 + 2;
1250        assert!(fdir[mid] != 0, "interior cell should have a flow direction");
1251    }
1252
1253    #[test]
1254    fn test_flow_accumulation_monotone() {
1255        let dem = tilted_dem(6, 6, 10.0, 1.0);
1256        let fdir = flow_direction_d8(&dem);
1257        let accum = flow_accumulation_d8(&dem, &fdir);
1258        // Each cell must accumulate at least 1
1259        for &a in &accum {
1260            assert!(a >= 1, "accumulation must be >= 1");
1261        }
1262    }
1263
1264    #[test]
1265    fn test_watershed_includes_outlet() {
1266        let dem = cone_dem(9, 9, 5.0);
1267        let fdir = flow_direction_d8(&dem);
1268        let mask = delineate_watershed(&dem, &fdir, 8, 4);
1269        assert!(mask[8 * 9 + 4], "outlet must be included in watershed");
1270    }
1271
1272    #[test]
1273    fn test_stream_extraction_threshold() {
1274        let accum = vec![1u32, 2, 5, 10, 20];
1275        let streams = extract_streams(&accum, 5);
1276        assert!(!streams[0]);
1277        assert!(!streams[1]);
1278        assert!(streams[2]);
1279        assert!(streams[3]);
1280        assert!(streams[4]);
1281    }
1282
1283    #[test]
1284    fn test_tri_flat_zero() {
1285        let dem = flat_dem(5, 5, 50.0);
1286        let tri = terrain_roughness_index(&dem);
1287        for &t in &tri {
1288            assert!(t.abs() < 1e-10, "TRI of flat DEM should be 0, got {}", t);
1289        }
1290    }
1291
1292    #[test]
1293    fn test_tri_non_negative() {
1294        let dem = cone_dem(7, 7, 10.0);
1295        let tri = terrain_roughness_index(&dem);
1296        for &t in &tri {
1297            assert!(t >= 0.0, "TRI must be non-negative");
1298        }
1299    }
1300
1301    #[test]
1302    fn test_tpi_flat_near_zero() {
1303        let dem = flat_dem(7, 7, 100.0);
1304        let tpi = terrain_position_index(&dem, 2);
1305        for &v in &tpi {
1306            assert!(v.abs() < 1e-9, "TPI on flat DEM should be 0, got {}", v);
1307        }
1308    }
1309
1310    #[test]
1311    fn test_twi_positive() {
1312        let dem = tilted_dem(6, 6, 10.0, 0.3);
1313        let fdir = flow_direction_d8(&dem);
1314        let accum = flow_accumulation_d8(&dem, &fdir);
1315        let slope = compute_slope(&dem);
1316        let twi = topographic_wetness_index(&dem, &accum, &slope);
1317        for &v in &twi {
1318            assert!(v.is_finite(), "TWI must be finite");
1319        }
1320    }
1321
1322    #[test]
1323    fn test_viewshed_observer_visible_to_itself() {
1324        let dem = flat_dem(5, 5, 0.0);
1325        let obs = Observer {
1326            row: 2,
1327            col: 2,
1328            height_above_terrain: 1.8,
1329            max_range: 1000.0,
1330        };
1331        let vs = compute_viewshed(&dem, &obs);
1332        assert!(vs[2 * 5 + 2], "observer cell must always be visible");
1333    }
1334
1335    #[test]
1336    fn test_viewshed_flat_all_visible() {
1337        let dem = flat_dem(5, 5, 0.0);
1338        let obs = Observer {
1339            row: 2,
1340            col: 2,
1341            height_above_terrain: 1.8,
1342            max_range: 1000.0,
1343        };
1344        let vs = compute_viewshed(&dem, &obs);
1345        for (i, &v) in vs.iter().enumerate() {
1346            assert!(v, "flat terrain: cell {} should be visible", i);
1347        }
1348    }
1349
1350    #[test]
1351    fn test_slope_stability_flat_infinite_fs() {
1352        let dem = flat_dem(3, 3, 0.0);
1353        let slope = compute_slope(&dem);
1354        let params = SlopeStabilityParams::default();
1355        let fs = slope_stability_index(&dem, &slope, &params);
1356        // On flat terrain, denominator → 0 → FS = MAX
1357        for &f in &fs {
1358            assert!(f >= 1.0, "FS on flat terrain should be large, got {}", f);
1359        }
1360    }
1361
1362    #[test]
1363    fn test_slope_stability_steep_low_fs() {
1364        // Very steep slope (close to vertical)
1365        let dem = tilted_dem(3, 3, 1.0, 10.0);
1366        let slope = compute_slope(&dem);
1367        let params = SlopeStabilityParams {
1368            cohesion: 0.0,
1369            friction_angle: 30.0_f64.to_radians(),
1370            soil_density: 1800.0,
1371            water_density: 1000.0,
1372            depth: 1.0,
1373            saturation: 1.0,
1374        };
1375        let fs = slope_stability_index(&dem, &slope, &params);
1376        // At least one cell should have FS < 1 (unstable)
1377        let has_unstable = fs.iter().any(|&f| f < 2.0 && f > 0.0);
1378        assert!(has_unstable, "steep saturated slope should show low FS");
1379    }
1380
1381    #[test]
1382    fn test_cos_incidence_overhead_sun() {
1383        // Sun directly overhead (zenith = 0), flat terrain → cos(0) = 1
1384        let solar = SolarPosition {
1385            zenith: 0.0,
1386            azimuth: 0.0,
1387        };
1388        let ci = cos_incidence(&solar, 0.0, 0.0);
1389        assert!(
1390            (ci - 1.0).abs() < 1e-9,
1391            "overhead sun on flat terrain: cos_incidence = {}",
1392            ci
1393        );
1394    }
1395
1396    #[test]
1397    fn test_cos_incidence_below_horizon() {
1398        // Sun below horizon (zenith > π/2) → result clamped to 0
1399        let solar = SolarPosition {
1400            zenith: PI * 0.6,
1401            azimuth: 0.0,
1402        };
1403        let ci = cos_incidence(&solar, 0.0, 0.0);
1404        assert!(ci >= 0.0, "cos_incidence must be non-negative");
1405    }
1406
1407    #[test]
1408    fn test_solar_irradiance_no_sun() {
1409        let dem = flat_dem(3, 3, 0.0);
1410        let slope = compute_slope(&dem);
1411        let aspect = compute_aspect(&dem);
1412        let solar = SolarPosition {
1413            zenith: PI / 2.0 + 0.1,
1414            azimuth: 0.0,
1415        }; // below horizon
1416        let irr = terrain_solar_irradiance(&dem, &slope, &aspect, &solar, 800.0, 100.0);
1417        for &v in &irr {
1418            // Should be diffuse only (small positive value)
1419            assert!(v >= 0.0, "irradiance must be non-negative");
1420        }
1421    }
1422
1423    #[test]
1424    fn test_raster_stats_uniform() {
1425        let data = vec![5.0f64; 100];
1426        let (min, max, mean, std) = raster_stats(&data);
1427        assert_eq!(min, 5.0);
1428        assert_eq!(max, 5.0);
1429        assert_eq!(mean, 5.0);
1430        assert!(std.abs() < 1e-12);
1431    }
1432
1433    #[test]
1434    fn test_normalise_raster_range() {
1435        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1436        let norm = normalise_raster(&data);
1437        assert!((norm[0]).abs() < 1e-9);
1438        assert!((norm[4] - 1.0).abs() < 1e-9);
1439    }
1440
1441    #[test]
1442    fn test_resample_dem_size() {
1443        let dem = flat_dem(8, 8, 100.0);
1444        let resampled = resample_dem(&dem, 2);
1445        assert_eq!(resampled.rows, 4);
1446        assert_eq!(resampled.cols, 4);
1447    }
1448
1449    #[test]
1450    fn test_fill_sinks_no_lower_than_input() {
1451        let dem = cone_dem(5, 5, 10.0);
1452        let filled = fill_sinks(&dem, 50);
1453        for (i, (&orig, &fill)) in dem.heights.iter().zip(filled.heights.iter()).enumerate() {
1454            assert!(
1455                fill >= orig - 1e-9,
1456                "filled cell {} should not be lower than original",
1457                i
1458            );
1459        }
1460    }
1461
1462    #[test]
1463    fn test_hypsometric_curve_bounds() {
1464        let dem = cone_dem(9, 9, 5.0);
1465        let curve = hypsometric_curve(&dem, 20);
1466        for &(h, a) in &curve {
1467            assert!((0.0..=1.0).contains(&h), "h out of [0,1]: {}", h);
1468            assert!(
1469                (0.0..=1.0).contains(&a),
1470                "area fraction out of [0,1]: {}",
1471                a
1472            );
1473        }
1474    }
1475
1476    #[test]
1477    fn test_hypsometric_integral_range() {
1478        let dem = cone_dem(9, 9, 5.0);
1479        let curve = hypsometric_curve(&dem, 50);
1480        let hi = hypsometric_integral(&curve);
1481        assert!(
1482            (0.0..=1.0).contains(&hi),
1483            "hypsometric integral out of [0,1]: {}",
1484            hi
1485        );
1486    }
1487
1488    #[test]
1489    fn test_classify_landform_ridge() {
1490        let lf = classify_landform(2.0, 2.0, 0.1);
1491        assert_eq!(lf, LandformClass::Ridge);
1492    }
1493
1494    #[test]
1495    fn test_classify_landform_valley() {
1496        let lf = classify_landform(-2.0, -2.0, 0.1);
1497        assert_eq!(lf, LandformClass::Valley);
1498    }
1499
1500    #[test]
1501    fn test_classify_landform_flat() {
1502        let lf = classify_landform(0.1, 0.1, 0.01);
1503        assert_eq!(lf, LandformClass::Flat);
1504    }
1505
1506    #[test]
1507    fn test_strahler_order_source_is_one() {
1508        let dem = tilted_dem(5, 10, 10.0, 1.0);
1509        let fdir = flow_direction_d8(&dem);
1510        let accum = flow_accumulation_d8(&dem, &fdir);
1511        let streams = extract_streams(&accum, 2);
1512        let ord = strahler_order(&dem, &fdir, &streams);
1513        for (i, (&s, &o)) in streams.iter().zip(ord.iter()).enumerate() {
1514            if s {
1515                assert!(o >= 1, "stream cell {} should have order >= 1", i);
1516            }
1517        }
1518    }
1519
1520    #[test]
1521    fn test_extract_contours_levels() {
1522        let dem = cone_dem(9, 9, 5.0);
1523        let levels = vec![80.0, 85.0, 90.0];
1524        let contours = extract_contours(&dem, &levels);
1525        assert!(!contours.is_empty(), "should find at least one contour");
1526        for c in &contours {
1527            assert!(
1528                !c.vertices.is_empty(),
1529                "contour at {} should have vertices",
1530                c.elevation
1531            );
1532        }
1533    }
1534
1535    #[test]
1536    fn test_ridge_valley_lines_length() {
1537        let dem = cone_dem(7, 7, 5.0);
1538        let rv = ridge_valley_lines(&dem);
1539        assert_eq!(rv.len(), dem.rows * dem.cols);
1540    }
1541
1542    #[test]
1543    fn test_dinf_flow_direction_range() {
1544        let dem = cone_dem(7, 7, 5.0);
1545        let fdir = flow_direction_dinf(&dem);
1546        for &a in &fdir {
1547            assert!(
1548                (0.0..=2.0 * PI + 0.01).contains(&a),
1549                "D-inf angle out of range: {}",
1550                a
1551            );
1552        }
1553    }
1554
1555    #[test]
1556    fn test_cumulative_viewshed_non_negative() {
1557        let dem = flat_dem(5, 5, 0.0);
1558        let observers = vec![
1559            Observer {
1560                row: 0,
1561                col: 0,
1562                height_above_terrain: 2.0,
1563                max_range: 500.0,
1564            },
1565            Observer {
1566                row: 4,
1567                col: 4,
1568                height_above_terrain: 2.0,
1569                max_range: 500.0,
1570            },
1571        ];
1572        let cv = cumulative_viewshed(&dem, &observers);
1573        for &v in &cv {
1574            assert!(v <= 2, "cumulative count cannot exceed number of observers");
1575        }
1576    }
1577
1578    #[test]
1579    fn test_fill_sinks_flat_unchanged() {
1580        let dem = flat_dem(5, 5, 100.0);
1581        let filled = fill_sinks(&dem, 10);
1582        for (&orig, &fill) in dem.heights.iter().zip(filled.heights.iter()) {
1583            assert!(
1584                (orig - fill).abs() < 1e-9,
1585                "flat DEM should be unchanged by fill_sinks"
1586            );
1587        }
1588    }
1589}