Skip to main content

oxigdal_terrain/hydrology/
flow_direction.rs

1//! Flow direction calculation for hydrological analysis.
2//!
3//! Implements D8 (8-direction) and D-Infinity algorithms for determining
4//! flow direction from each cell based on elevation gradients.
5
6use crate::error::{Result, TerrainError};
7use num_traits::Float;
8use scirs2_core::prelude::*;
9
10/// Flow direction algorithm
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum FlowAlgorithm {
13    /// D8: 8 cardinal and diagonal directions
14    D8,
15    /// D-Infinity: continuous flow direction
16    DInfinity,
17}
18
19/// D8 flow direction codes (ArcGIS convention)
20/// 1=E, 2=SE, 4=S, 8=SW, 16=W, 32=NW, 64=N, 128=NE
21pub(crate) const D8_DIRS: [(isize, isize, u8); 8] = [
22    (0, 1, 1),    // East
23    (1, 1, 2),    // Southeast
24    (1, 0, 4),    // South
25    (1, -1, 8),   // Southwest
26    (0, -1, 16),  // West
27    (-1, -1, 32), // Northwest
28    (-1, 0, 64),  // North
29    (-1, 1, 128), // Northeast
30];
31
32/// Calculate D8 flow direction.
33///
34/// Returns an array where each cell contains a power-of-2 value indicating
35/// the direction of steepest descent.
36pub fn flow_direction_d8<T>(
37    dem: &Array2<T>,
38    cell_size: f64,
39    nodata: Option<T>,
40) -> Result<Array2<u8>>
41where
42    T: Float + Into<f64> + Copy,
43{
44    validate_inputs(dem, cell_size)?;
45
46    let (height, width) = dem.dim();
47    let mut flow_dir = Array2::zeros((height, width));
48
49    // Cell size for diagonal directions
50    let diag_size = cell_size * 2.0_f64.sqrt();
51
52    for y in 0..height {
53        for x in 0..width {
54            let center = dem[[y, x]];
55
56            if let Some(nd) = nodata {
57                if is_nodata(center, nd) {
58                    flow_dir[[y, x]] = 0;
59                    continue;
60                }
61            }
62
63            let center_val = center.into();
64            let mut max_slope = f64::NEG_INFINITY;
65            let mut max_dir = 0_u8;
66
67            // Check all 8 neighbors
68            for (dy, dx, dir_code) in &D8_DIRS {
69                let ny = y as isize + dy;
70                let nx = x as isize + dx;
71
72                if ny >= 0 && ny < height as isize && nx >= 0 && nx < width as isize {
73                    let neighbor = dem[[ny as usize, nx as usize]];
74
75                    if let Some(nd) = nodata {
76                        if is_nodata(neighbor, nd) {
77                            continue;
78                        }
79                    }
80
81                    let neighbor_val = neighbor.into();
82                    let elevation_diff = center_val - neighbor_val;
83
84                    // Calculate distance
85                    let distance = if dy.abs() == 1 && dx.abs() == 1 {
86                        diag_size
87                    } else {
88                        cell_size
89                    };
90
91                    let slope = elevation_diff / distance;
92
93                    if slope > max_slope {
94                        max_slope = slope;
95                        max_dir = *dir_code;
96                    }
97                }
98            }
99
100            // If no downslope direction found, mark as sink
101            flow_dir[[y, x]] = max_dir;
102        }
103    }
104
105    Ok(flow_dir)
106}
107
108/// Calculate D-Infinity flow direction.
109///
110/// Returns an array where each cell contains a continuous direction angle
111/// in radians (0 to 2π).
112pub fn flow_direction_dinf<T>(
113    dem: &Array2<T>,
114    cell_size: f64,
115    nodata: Option<T>,
116) -> Result<Array2<f64>>
117where
118    T: Float + Into<f64> + Copy,
119{
120    validate_inputs(dem, cell_size)?;
121
122    let (height, width) = dem.dim();
123    let mut flow_dir = Array2::zeros((height, width));
124
125    for y in 1..height - 1 {
126        for x in 1..width - 1 {
127            let center = dem[[y, x]];
128
129            if let Some(nd) = nodata {
130                if is_nodata(center, nd) {
131                    flow_dir[[y, x]] = -1.0;
132                    continue;
133                }
134            }
135
136            let z = center.into();
137
138            // Get 3x3 neighborhood
139            let e0 = dem[[y, x + 1]].into();
140            let e1 = dem[[y - 1, x + 1]].into();
141            let e2 = dem[[y - 1, x]].into();
142            let e3 = dem[[y - 1, x - 1]].into();
143            let e4 = dem[[y, x - 1]].into();
144            let e5 = dem[[y + 1, x - 1]].into();
145            let e6 = dem[[y + 1, x]].into();
146            let e7 = dem[[y + 1, x + 1]].into();
147
148            // Calculate slopes in 8 facets
149            let facets = [
150                (e0, e1, 0.0),                               // Facet 0
151                (e2, e1, std::f64::consts::FRAC_PI_4),       // Facet 1
152                (e2, e3, std::f64::consts::FRAC_PI_2),       // Facet 2
153                (e4, e3, 3.0 * std::f64::consts::FRAC_PI_4), // Facet 3
154                (e4, e5, std::f64::consts::PI),              // Facet 4
155                (e6, e5, 5.0 * std::f64::consts::FRAC_PI_4), // Facet 5
156                (e6, e7, 3.0 * std::f64::consts::FRAC_PI_2), // Facet 6
157                (e0, e7, 7.0 * std::f64::consts::FRAC_PI_4), // Facet 7
158            ];
159
160            let mut max_slope = f64::NEG_INFINITY;
161            let mut flow_angle = 0.0;
162
163            for (e1_val, e2_val, base_angle) in &facets {
164                let s1 = (z - e1_val) / cell_size;
165                let s2 = (z - e2_val) / (cell_size * 2.0_f64.sqrt());
166
167                let r = (s1 * s1 + s2 * s2).sqrt();
168                let angle = s2.atan2(s1);
169
170                if r > max_slope {
171                    max_slope = r;
172                    flow_angle = base_angle + angle;
173                }
174            }
175
176            // Normalize to 0-2π
177            if flow_angle < 0.0 {
178                flow_angle += 2.0 * std::f64::consts::PI;
179            }
180            if flow_angle >= 2.0 * std::f64::consts::PI {
181                flow_angle -= 2.0 * std::f64::consts::PI;
182            }
183
184            flow_dir[[y, x]] = flow_angle;
185        }
186    }
187
188    Ok(flow_dir)
189}
190
191/// Calculate flow direction with specified algorithm.
192pub fn flow_direction<T>(
193    dem: &Array2<T>,
194    cell_size: f64,
195    algorithm: FlowAlgorithm,
196    nodata: Option<T>,
197) -> Result<Array2<f64>>
198where
199    T: Float + Into<f64> + Copy,
200{
201    match algorithm {
202        FlowAlgorithm::D8 => {
203            let d8 = flow_direction_d8(dem, cell_size, nodata)?;
204            let (height, width) = d8.dim();
205            let mut result = Array2::zeros((height, width));
206            for y in 0..height {
207                for x in 0..width {
208                    result[[y, x]] = d8[[y, x]] as f64;
209                }
210            }
211            Ok(result)
212        }
213        FlowAlgorithm::DInfinity => flow_direction_dinf(dem, cell_size, nodata),
214    }
215}
216
217// Helper functions
218
219fn validate_inputs<T>(dem: &Array2<T>, cell_size: f64) -> Result<()> {
220    let (height, width) = dem.dim();
221
222    if height < 3 || width < 3 {
223        return Err(TerrainError::InvalidDimensions { width, height });
224    }
225
226    if cell_size <= 0.0 {
227        return Err(TerrainError::InvalidCellSize { size: cell_size });
228    }
229
230    Ok(())
231}
232
233fn is_nodata<T: Float>(value: T, nodata: T) -> bool {
234    if value.is_nan() && nodata.is_nan() {
235        true
236    } else {
237        (value - nodata).abs() < T::epsilon()
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn test_d8_simple_slope() {
247        // Create simple east-facing slope
248        let mut dem = Array2::zeros((5, 5));
249        for y in 0..5 {
250            for x in 0..5 {
251                dem[[y, x]] = 100.0 - (x as f64) * 10.0; // Decreases eastward
252            }
253        }
254
255        let flow_dir = flow_direction_d8(&dem, 10.0, None).expect("flow direction failed");
256
257        // Most cells should flow east (code 1)
258        for y in 1..4 {
259            for x in 1..3 {
260                assert_eq!(flow_dir[[y, x]], 1, "should flow east");
261            }
262        }
263    }
264
265    #[test]
266    fn test_d8_directions() {
267        // Create a pit at center
268        let mut dem = Array2::from_elem((5, 5), 100.0);
269        dem[[2, 2]] = 50.0; // Central pit
270
271        let flow_dir = flow_direction_d8(&dem, 10.0, None).expect("flow direction failed");
272
273        // Neighbors should flow toward center
274        assert!(flow_dir[[2, 1]] > 0); // West neighbor
275        assert!(flow_dir[[2, 3]] > 0); // East neighbor
276        assert!(flow_dir[[1, 2]] > 0); // North neighbor
277        assert!(flow_dir[[3, 2]] > 0); // South neighbor
278    }
279
280    #[test]
281    fn test_dinf_continuous() {
282        let mut dem = Array2::zeros((5, 5));
283        for y in 0..5 {
284            for x in 0..5 {
285                dem[[y, x]] = 100.0 - (x as f64) * 10.0;
286            }
287        }
288
289        let flow_dir = flow_direction_dinf(&dem, 10.0, None).expect("D-Infinity failed");
290
291        // Flow directions should be continuous values
292        for y in 1..4 {
293            for x in 1..4 {
294                assert!(flow_dir[[y, x]] >= 0.0 && flow_dir[[y, x]] < 2.0 * std::f64::consts::PI);
295            }
296        }
297    }
298}