Skip to main content

oxiphysics_geometry/
terrain_processing.rs

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