1use std::f64::consts::PI;
22
23#[derive(Debug, Clone)]
33pub struct Dem {
34 pub heights: Vec<f64>,
36 pub rows: usize,
38 pub cols: usize,
40 pub cell_size: f64,
42 pub nodata: f64,
44}
45
46impl Dem {
47 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 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 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 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 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
103pub 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 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 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
129pub 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 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#[derive(Debug, Clone, Copy, PartialEq)]
166pub enum CurvatureKind {
167 Profile,
169 Planform,
171 Tangential,
173}
174
175pub 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 let z5 = w[1][1];
189 let _ = z5; 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
227pub type D8Code = u8;
236
237pub 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
251fn 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
262pub 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
293pub fn flow_direction_dinf(dem: &Dem) -> Vec<f64> {
299 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 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
353pub 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 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 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
393pub 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 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 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
446pub fn extract_streams(accum: &[u32], threshold: u32) -> Vec<bool> {
455 accum.iter().map(|&a| a >= threshold).collect()
456}
457
458pub 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 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 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
512pub 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
542pub 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#[derive(Debug, Clone, Copy, PartialEq)]
586pub enum LandformClass {
587 Ridge,
589 UpperSlope,
591 MiddleSlope,
593 Flat,
595 LowerSlope,
597 Valley,
599}
600
601pub fn classify_landform(tpi_small: f64, tpi_large: f64, slope_rad: f64) -> LandformClass {
603 let std_small = 1.0; 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
623pub 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; let beta = slope[i].max(1e-6_f64.atan()); twi[i] = (a / beta.tan()).ln();
639 }
640 twi
641}
642
643#[derive(Debug, Clone, Copy)]
649pub struct Observer {
650 pub row: usize,
652 pub col: usize,
654 pub height_above_terrain: f64,
656 pub max_range: f64,
658}
659
660pub 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 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
711pub 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#[derive(Debug, Clone, Copy)]
732pub struct SlopeStabilityParams {
733 pub cohesion: f64,
735 pub friction_angle: f64,
737 pub soil_density: f64,
739 pub water_density: f64,
741 pub depth: f64,
743 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
760pub 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#[derive(Debug, Clone, Copy)]
797pub struct SolarPosition {
798 pub zenith: f64,
800 pub azimuth: f64,
802}
803
804impl SolarPosition {
805 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 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
827pub 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
841pub 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 let direct = if cos_z > 1e-6 { dni * ci } else { 0.0 };
860 let diffuse = diffuse_horizontal * (1.0 + slope[i].cos()) / 2.0;
862 irr[i] = direct + diffuse;
863 }
864 irr
865}
866
867pub 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
895pub 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
905pub 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
922pub 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#[derive(Debug, Clone)]
968pub struct Contour {
969 pub elevation: f64,
971 pub vertices: Vec<(f64, f64)>,
973}
974
975pub 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 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 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 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
1039pub 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
1064pub 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#[derive(Debug, Clone, Copy, PartialEq)]
1086pub enum RidgeValley {
1087 Ridge,
1089 Valley,
1091 Slope,
1093}
1094
1095pub 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#[cfg(test)]
1118mod tests {
1119 use super::*;
1120
1121 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 fn flat_dem(rows: usize, cols: usize, elevation: f64) -> Dem {
1139 Dem::new(vec![elevation; rows * cols], rows, cols, 10.0)
1140 }
1141
1142 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 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 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 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, ¶ms);
1352 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 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, ¶ms);
1372 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 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 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 }; let irr = terrain_solar_irradiance(&dem, &slope, &aspect, &solar, 800.0, 100.0);
1413 for &v in &irr {
1414 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}