Skip to main content

oxigdal_algorithms/raster/
viewshed.rs

1//! Viewshed analysis for visibility computation
2//!
3//! Provides multiple algorithms for determining visibility from one or more
4//! observer points across a digital elevation model (DEM).
5//!
6//! # Algorithms
7//!
8//! ## R1: Line-of-sight sampling (basic)
9//!
10//! Samples elevation along rays from observer to each target cell.
11//! Simple and correct, but O(n^3) for an n x n raster.
12//!
13//! ## R2: Reference plane method
14//!
15//! Uses a sweep-based approach that processes cells along radial lines from
16//! the observer. Each line is traced outward, maintaining the maximum angle
17//! seen so far. More efficient than R1 for large rasters.
18//!
19//! ## R3: Wang et al. (2000) sweep line
20//!
21//! An O(n^2 log n) algorithm that sweeps a rotating line around the observer.
22//! Uses a balanced binary search tree (approximated here) to efficiently track
23//! which cells are visible. Most efficient for very large rasters.
24//!
25//! # Features
26//!
27//! - **Earth curvature correction**: Adjusts effective elevations for Earth's
28//!   curvature and atmospheric refraction (using coefficient of refraction k).
29//! - **Multiple observer support**: Cumulative viewshed from many observers.
30//! - **Height offsets**: Observer and target height above ground level.
31//! - **Maximum distance**: Limit the analysis radius.
32//! - **Fresnel zone analysis**: Approximate radio wave propagation line-of-sight.
33//!
34//! # References
35//!
36//! - Wang, J. et al. (2000). "Efficient viewshed algorithms on raster terrain."
37//! - De Floriani, L. & Magillo, P. (2003). "Algorithms for visibility computation on terrains."
38//! - Franklin, W.R. & Ray, C.K. (1994). "Higher isn't necessarily better."
39
40use crate::error::{AlgorithmError, Result};
41use oxigdal_core::buffer::RasterBuffer;
42use oxigdal_core::types::RasterDataType;
43
44#[cfg(feature = "parallel")]
45use rayon::prelude::*;
46
47// ===========================================================================
48// Constants
49// ===========================================================================
50
51/// Mean radius of the Earth in metres (IUGG 2015 best estimate).
52const EARTH_RADIUS_M: f64 = 6_371_000.0;
53
54/// Standard atmospheric refraction coefficient for standard atmosphere (ICAO/ITU-R, k ≈ 0.13).
55///
56/// The refraction coefficient `k` reduces the apparent elevation of distant terrain by bending
57/// radio/optical rays downward toward Earth's surface. A combined curvature–refraction correction
58/// is applied as `offset = d² / (2 · R_eff)` where `R_eff = R / (1 − k)`.  For standard
59/// atmosphere (ISA, sea-level, 15 °C, 1013.25 hPa) k ≈ 0.13; some authorities quote 0.12–0.14.
60/// ITU-R P.526 uses an effective Earth-radius factor k_e = 4/3, corresponding to k = 0.25 for
61/// radio propagation; the geodetic/surveying default here follows ICAO Annex 15 / standard
62/// atmospheric tables (k = 0.13).
63const REFRACTION_COEFF: f64 = 0.13;
64
65// ===========================================================================
66// Configuration types
67// ===========================================================================
68
69/// Algorithm to use for viewshed computation
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
71pub enum ViewshedAlgorithm {
72    /// R1: Line-of-sight ray sampling (basic, accurate)
73    #[default]
74    R1LineOfSight,
75
76    /// R2: Reference plane / radial sweep (faster for large rasters)
77    R2ReferencePlane,
78
79    /// R3: Sweep line with angular sorting (most efficient for huge rasters)
80    R3SweepLine,
81}
82
83/// Earth curvature correction parameters
84#[derive(Debug, Clone, Copy)]
85pub struct CurvatureCorrection {
86    /// Earth radius in same units as cell_size (e.g., 6371000.0 for meters)
87    pub earth_radius: f64,
88
89    /// Atmospheric refraction coefficient `k` (standard atmosphere value: 0.13, per ICAO/ITU-R).
90    ///
91    /// The effective Earth radius used for correction is `R_eff = earth_radius / (1 − k)`.
92    /// Default: 0.13 (ICAO/ITU-R standard atmosphere, geodetic/surveying convention).
93    pub refraction_coefficient: f64,
94}
95
96impl Default for CurvatureCorrection {
97    fn default() -> Self {
98        Self {
99            earth_radius: EARTH_RADIUS_M,
100            refraction_coefficient: REFRACTION_COEFF,
101        }
102    }
103}
104
105/// Configuration for viewshed analysis
106#[derive(Debug, Clone)]
107pub struct ViewshedConfig {
108    /// Observer X coordinate (column)
109    pub observer_x: u64,
110    /// Observer Y coordinate (row)
111    pub observer_y: u64,
112    /// Observer height above ground (meters)
113    pub observer_height: f64,
114    /// Target height above ground (meters)
115    pub target_height: f64,
116    /// Maximum viewing distance (None = unlimited)
117    pub max_distance: Option<f64>,
118    /// Cell size (ground distance per pixel)
119    pub cell_size: f64,
120    /// Algorithm to use
121    pub algorithm: ViewshedAlgorithm,
122    /// Earth curvature correction (None = no correction)
123    pub curvature_correction: Option<CurvatureCorrection>,
124}
125
126/// An observer point with location and height
127#[derive(Debug, Clone, Copy)]
128pub struct ObserverPoint {
129    /// X coordinate (column)
130    pub x: u64,
131    /// Y coordinate (row)
132    pub y: u64,
133    /// Height above ground
134    pub height: f64,
135}
136
137/// Result of a viewshed analysis
138#[derive(Debug)]
139pub struct ViewshedResult {
140    /// Binary visibility raster (1.0 = visible, 0.0 = not visible)
141    pub visibility: RasterBuffer,
142    /// Elevation angle raster (angle from horizontal at observer to each visible cell, in radians)
143    /// Positive = above horizon, negative = below. Only set for visible cells.
144    pub elevation_angle: Option<RasterBuffer>,
145}
146
147// ===========================================================================
148// Main entry points
149// ===========================================================================
150
151/// Computes viewshed from a single observer point (backward-compatible)
152///
153/// Returns a binary raster where 1 = visible, 0 = not visible.
154///
155/// # Arguments
156///
157/// * `dem` - Digital elevation model
158/// * `observer_x` - X coordinate of observer
159/// * `observer_y` - Y coordinate of observer
160/// * `observer_height` - Height of observer above ground
161/// * `target_height` - Height of targets above ground
162/// * `max_distance` - Maximum viewing distance (None = unlimited)
163/// * `cell_size` - Size of each cell
164///
165/// # Errors
166///
167/// Returns an error if the observer is outside the DEM bounds
168pub fn compute_viewshed(
169    dem: &RasterBuffer,
170    observer_x: u64,
171    observer_y: u64,
172    observer_height: f64,
173    target_height: f64,
174    max_distance: Option<f64>,
175    cell_size: f64,
176) -> Result<RasterBuffer> {
177    let config = ViewshedConfig {
178        observer_x,
179        observer_y,
180        observer_height,
181        target_height,
182        max_distance,
183        cell_size,
184        algorithm: ViewshedAlgorithm::R1LineOfSight,
185        curvature_correction: None,
186    };
187
188    let result = compute_viewshed_advanced(dem, &config)?;
189    Ok(result.visibility)
190}
191
192/// Computes viewshed with full configuration
193///
194/// # Arguments
195///
196/// * `dem` - Digital elevation model
197/// * `config` - Full viewshed configuration
198///
199/// # Errors
200///
201/// Returns an error if the observer is outside the DEM bounds
202pub fn compute_viewshed_advanced(
203    dem: &RasterBuffer,
204    config: &ViewshedConfig,
205) -> Result<ViewshedResult> {
206    // Validate observer position
207    if config.observer_x >= dem.width() || config.observer_y >= dem.height() {
208        return Err(AlgorithmError::InvalidParameter {
209            parameter: "observer position",
210            message: format!(
211                "Observer ({}, {}) is outside DEM bounds ({}, {})",
212                config.observer_x,
213                config.observer_y,
214                dem.width(),
215                dem.height()
216            ),
217        });
218    }
219
220    match config.algorithm {
221        ViewshedAlgorithm::R1LineOfSight => viewshed_r1(dem, config),
222        ViewshedAlgorithm::R2ReferencePlane => viewshed_r2(dem, config),
223        ViewshedAlgorithm::R3SweepLine => viewshed_r3(dem, config),
224    }
225}
226
227/// Computes cumulative viewshed from multiple observers
228///
229/// Returns a raster with count of observers that can see each cell.
230///
231/// # Arguments
232///
233/// * `dem` - Digital elevation model
234/// * `observers` - List of (x, y, height) tuples
235/// * `target_height` - Height of targets above ground
236/// * `max_distance` - Maximum viewing distance
237/// * `cell_size` - Cell size
238///
239/// # Errors
240///
241/// Returns an error if any observer is outside the DEM bounds
242pub fn compute_cumulative_viewshed(
243    dem: &RasterBuffer,
244    observers: &[(u64, u64, f64)],
245    target_height: f64,
246    max_distance: Option<f64>,
247    cell_size: f64,
248) -> Result<RasterBuffer> {
249    let observer_points: Vec<ObserverPoint> = observers
250        .iter()
251        .map(|&(x, y, height)| ObserverPoint { x, y, height })
252        .collect();
253
254    compute_cumulative_viewshed_advanced(
255        dem,
256        &observer_points,
257        target_height,
258        max_distance,
259        cell_size,
260        None,
261    )
262}
263
264/// Computes cumulative viewshed with advanced options
265///
266/// # Arguments
267///
268/// * `dem` - Digital elevation model
269/// * `observers` - List of observer points
270/// * `target_height` - Target height above ground
271/// * `max_distance` - Maximum distance
272/// * `cell_size` - Cell size
273/// * `curvature` - Optional earth curvature correction
274///
275/// # Errors
276///
277/// Returns an error if any observer is outside the DEM bounds
278pub fn compute_cumulative_viewshed_advanced(
279    dem: &RasterBuffer,
280    observers: &[ObserverPoint],
281    target_height: f64,
282    max_distance: Option<f64>,
283    cell_size: f64,
284    curvature: Option<CurvatureCorrection>,
285) -> Result<RasterBuffer> {
286    let width = dem.width();
287    let height = dem.height();
288    let mut cumulative = RasterBuffer::zeros(width, height, RasterDataType::Float64);
289
290    #[cfg(feature = "parallel")]
291    {
292        let viewsheds: Result<Vec<RasterBuffer>> = observers
293            .par_iter()
294            .map(|obs| {
295                let config = ViewshedConfig {
296                    observer_x: obs.x,
297                    observer_y: obs.y,
298                    observer_height: obs.height,
299                    target_height,
300                    max_distance,
301                    cell_size,
302                    algorithm: ViewshedAlgorithm::R2ReferencePlane,
303                    curvature_correction: curvature,
304                };
305                let result = compute_viewshed_advanced(dem, &config)?;
306                Ok(result.visibility)
307            })
308            .collect();
309
310        for viewshed in viewsheds? {
311            for y in 0..height {
312                for x in 0..width {
313                    let visible = viewshed.get_pixel(x, y).map_err(AlgorithmError::Core)?;
314                    if visible > 0.0 {
315                        let current = cumulative.get_pixel(x, y).map_err(AlgorithmError::Core)?;
316                        cumulative
317                            .set_pixel(x, y, current + 1.0)
318                            .map_err(AlgorithmError::Core)?;
319                    }
320                }
321            }
322        }
323    }
324
325    #[cfg(not(feature = "parallel"))]
326    {
327        for obs in observers {
328            let config = ViewshedConfig {
329                observer_x: obs.x,
330                observer_y: obs.y,
331                observer_height: obs.height,
332                target_height,
333                max_distance,
334                cell_size,
335                algorithm: ViewshedAlgorithm::R2ReferencePlane,
336                curvature_correction: curvature,
337            };
338            let result = compute_viewshed_advanced(dem, &config)?;
339
340            for y in 0..height {
341                for x in 0..width {
342                    let visible = result
343                        .visibility
344                        .get_pixel(x, y)
345                        .map_err(AlgorithmError::Core)?;
346                    if visible > 0.0 {
347                        let current = cumulative.get_pixel(x, y).map_err(AlgorithmError::Core)?;
348                        cumulative
349                            .set_pixel(x, y, current + 1.0)
350                            .map_err(AlgorithmError::Core)?;
351                    }
352                }
353            }
354        }
355    }
356
357    Ok(cumulative)
358}
359
360/// Computes line-of-sight profile between two points
361///
362/// Returns a vector of (distance, terrain_elevation, los_elevation) tuples
363/// showing the terrain profile and the line-of-sight line.
364///
365/// # Arguments
366///
367/// * `dem` - Digital elevation model
368/// * `from_x`, `from_y` - Observer coordinates
369/// * `from_height` - Observer height above ground
370/// * `to_x`, `to_y` - Target coordinates
371/// * `cell_size` - Cell size
372/// * `curvature` - Optional earth curvature correction
373///
374/// # Errors
375///
376/// Returns an error if coordinates are out of bounds
377pub fn compute_los_profile(
378    dem: &RasterBuffer,
379    from_x: u64,
380    from_y: u64,
381    from_height: f64,
382    to_x: u64,
383    to_y: u64,
384    cell_size: f64,
385    curvature: Option<CurvatureCorrection>,
386) -> Result<Vec<(f64, f64, f64)>> {
387    let observer_elev = dem
388        .get_pixel(from_x, from_y)
389        .map_err(AlgorithmError::Core)?
390        + from_height;
391
392    let target_elev = dem.get_pixel(to_x, to_y).map_err(AlgorithmError::Core)?;
393
394    let dx = (to_x as f64 - from_x as f64) * cell_size;
395    let dy = (to_y as f64 - from_y as f64) * cell_size;
396    let total_distance = (dx * dx + dy * dy).sqrt();
397
398    if total_distance < 1e-10 {
399        return Ok(vec![(0.0, observer_elev, observer_elev)]);
400    }
401
402    let num_samples = (total_distance / (cell_size * 0.5)).ceil() as usize;
403    let num_samples = num_samples.max(2);
404
405    let mut profile = Vec::with_capacity(num_samples);
406
407    for i in 0..num_samples {
408        let t = i as f64 / (num_samples - 1) as f64;
409        let px = from_x as f64 + (to_x as f64 - from_x as f64) * t;
410        let py = from_y as f64 + (to_y as f64 - from_y as f64) * t;
411
412        let ix = (px.round() as u64).min(dem.width() - 1);
413        let iy = (py.round() as u64).min(dem.height() - 1);
414
415        let dist = t * total_distance;
416        let mut terrain_elev = dem.get_pixel(ix, iy).map_err(AlgorithmError::Core)?;
417
418        // Apply curvature correction to terrain
419        if let Some(ref curv) = curvature {
420            terrain_elev -= earth_curvature_offset(dist, curv);
421        }
422
423        // Line-of-sight elevation at this distance
424        let los_elev = observer_elev + (target_elev - observer_elev) * t;
425
426        profile.push((dist, terrain_elev, los_elev));
427    }
428
429    Ok(profile)
430}
431
432/// Performs Fresnel zone clearance analysis
433///
434/// Checks whether a radio link between two points has adequate Fresnel zone clearance.
435/// Returns the minimum clearance ratio (clearance / Fresnel radius) along the path.
436/// Values >= 0.6 typically indicate adequate clearance.
437///
438/// # Arguments
439///
440/// * `dem` - Digital elevation model
441/// * `from_x`, `from_y` - Transmitter coordinates
442/// * `from_height` - Transmitter height above ground
443/// * `to_x`, `to_y` - Receiver coordinates
444/// * `to_height` - Receiver height above ground
445/// * `cell_size` - Cell size
446/// * `frequency_ghz` - Radio frequency in GHz
447/// * `curvature` - Optional earth curvature correction
448///
449/// # Errors
450///
451/// Returns an error if coordinates are out of bounds
452pub fn compute_fresnel_clearance(
453    dem: &RasterBuffer,
454    from_x: u64,
455    from_y: u64,
456    from_height: f64,
457    to_x: u64,
458    to_y: u64,
459    to_height: f64,
460    cell_size: f64,
461    frequency_ghz: f64,
462    curvature: Option<CurvatureCorrection>,
463) -> Result<f64> {
464    let obs_elev = dem
465        .get_pixel(from_x, from_y)
466        .map_err(AlgorithmError::Core)?
467        + from_height;
468    let tgt_elev = dem.get_pixel(to_x, to_y).map_err(AlgorithmError::Core)? + to_height;
469
470    let dx = (to_x as f64 - from_x as f64) * cell_size;
471    let dy = (to_y as f64 - from_y as f64) * cell_size;
472    let total_distance = (dx * dx + dy * dy).sqrt();
473
474    if total_distance < 1e-10 {
475        return Ok(f64::INFINITY);
476    }
477
478    // Wavelength in meters
479    let wavelength = 0.3 / frequency_ghz;
480
481    let num_samples = (total_distance / (cell_size * 0.5)).ceil() as usize;
482    let num_samples = num_samples.max(2);
483
484    let mut min_clearance_ratio = f64::INFINITY;
485
486    for i in 1..(num_samples - 1) {
487        let t = i as f64 / (num_samples - 1) as f64;
488        let px = from_x as f64 + (to_x as f64 - from_x as f64) * t;
489        let py = from_y as f64 + (to_y as f64 - from_y as f64) * t;
490
491        let ix = (px.round() as u64).min(dem.width() - 1);
492        let iy = (py.round() as u64).min(dem.height() - 1);
493
494        let dist_from = t * total_distance;
495        let dist_to = (1.0 - t) * total_distance;
496
497        let mut terrain_elev = dem.get_pixel(ix, iy).map_err(AlgorithmError::Core)?;
498
499        if let Some(ref curv) = curvature {
500            terrain_elev -= earth_curvature_offset(dist_from, curv);
501        }
502
503        // LOS elevation at this point
504        let los_elev = obs_elev + (tgt_elev - obs_elev) * t;
505
506        // First Fresnel zone radius at this point
507        let fresnel_radius = (wavelength * dist_from * dist_to / total_distance).sqrt();
508
509        // Clearance = LOS elevation - terrain elevation
510        let clearance = los_elev - terrain_elev;
511        let ratio = if fresnel_radius > 1e-10 {
512            clearance / fresnel_radius
513        } else {
514            if clearance >= 0.0 {
515                f64::INFINITY
516            } else {
517                f64::NEG_INFINITY
518            }
519        };
520
521        if ratio < min_clearance_ratio {
522            min_clearance_ratio = ratio;
523        }
524    }
525
526    Ok(min_clearance_ratio)
527}
528
529// ===========================================================================
530// R1: Line-of-sight sampling
531// ===========================================================================
532
533/// R1 algorithm: sample points along line from observer to each target cell
534fn viewshed_r1(dem: &RasterBuffer, config: &ViewshedConfig) -> Result<ViewshedResult> {
535    let width = dem.width();
536    let height = dem.height();
537    let mut visibility = RasterBuffer::zeros(width, height, RasterDataType::UInt8);
538    let mut elev_angle = RasterBuffer::zeros(width, height, RasterDataType::Float64);
539
540    let observer_elev = dem
541        .get_pixel(config.observer_x, config.observer_y)
542        .map_err(AlgorithmError::Core)?
543        + config.observer_height;
544
545    for y in 0..height {
546        for x in 0..width {
547            if x == config.observer_x && y == config.observer_y {
548                visibility
549                    .set_pixel(x, y, 1.0)
550                    .map_err(AlgorithmError::Core)?;
551                continue;
552            }
553
554            let dx = (x as f64 - config.observer_x as f64) * config.cell_size;
555            let dy = (y as f64 - config.observer_y as f64) * config.cell_size;
556            let distance = (dx * dx + dy * dy).sqrt();
557
558            if let Some(max_dist) = config.max_distance {
559                if distance > max_dist {
560                    continue;
561                }
562            }
563
564            let target_elev =
565                dem.get_pixel(x, y).map_err(AlgorithmError::Core)? + config.target_height;
566
567            let (is_vis, angle) = check_line_of_sight(
568                dem,
569                config.observer_x,
570                config.observer_y,
571                observer_elev,
572                x,
573                y,
574                target_elev,
575                config.cell_size,
576                &config.curvature_correction,
577            )?;
578
579            if is_vis {
580                visibility
581                    .set_pixel(x, y, 1.0)
582                    .map_err(AlgorithmError::Core)?;
583                elev_angle
584                    .set_pixel(x, y, angle)
585                    .map_err(AlgorithmError::Core)?;
586            }
587        }
588    }
589
590    Ok(ViewshedResult {
591        visibility,
592        elevation_angle: Some(elev_angle),
593    })
594}
595
596/// Checks line of sight between two points with curvature correction
597fn check_line_of_sight(
598    dem: &RasterBuffer,
599    x0: u64,
600    y0: u64,
601    elev0: f64,
602    x1: u64,
603    y1: u64,
604    elev1: f64,
605    cell_size: f64,
606    curvature: &Option<CurvatureCorrection>,
607) -> Result<(bool, f64)> {
608    let dx = (x1 as f64 - x0 as f64) * cell_size;
609    let dy = (y1 as f64 - y0 as f64) * cell_size;
610    let total_distance = (dx * dx + dy * dy).sqrt();
611
612    if total_distance < 1e-10 {
613        return Ok((true, 0.0));
614    }
615
616    // Angle from observer to target
617    let target_angle = (elev1 - elev0).atan2(total_distance);
618
619    let steps = ((total_distance / cell_size) * 2.0).max(2.0) as usize;
620    let mut max_angle = f64::NEG_INFINITY;
621
622    for i in 1..steps {
623        let t = i as f64 / steps as f64;
624        let px = x0 as f64 + (x1 as f64 - x0 as f64) * t;
625        let py = y0 as f64 + (y1 as f64 - y0 as f64) * t;
626
627        let ix = px.round() as u64;
628        let iy = py.round() as u64;
629
630        if ix >= dem.width() || iy >= dem.height() {
631            continue;
632        }
633
634        let mut terrain_elev = dem.get_pixel(ix, iy).map_err(AlgorithmError::Core)?;
635        let curr_dist = t * total_distance;
636
637        // Apply curvature correction
638        if let Some(curv) = curvature {
639            terrain_elev -= earth_curvature_offset(curr_dist, curv);
640        }
641
642        let angle = (terrain_elev - elev0).atan2(curr_dist);
643
644        if angle > max_angle {
645            max_angle = angle;
646        }
647
648        if angle > target_angle + 1e-9 {
649            return Ok((false, 0.0));
650        }
651    }
652
653    Ok((true, target_angle))
654}
655
656// ===========================================================================
657// R2: Reference plane / radial sweep
658// ===========================================================================
659
660/// R2 algorithm: sweep radial lines outward from observer
661///
662/// For each discrete angle from the observer, trace a ray outward through
663/// the grid cells. Maintain the maximum elevation angle seen so far. A cell
664/// is visible if its elevation angle exceeds the current maximum.
665fn viewshed_r2(dem: &RasterBuffer, config: &ViewshedConfig) -> Result<ViewshedResult> {
666    let width = dem.width();
667    let height = dem.height();
668    let mut visibility = RasterBuffer::zeros(width, height, RasterDataType::UInt8);
669
670    let observer_elev = dem
671        .get_pixel(config.observer_x, config.observer_y)
672        .map_err(AlgorithmError::Core)?
673        + config.observer_height;
674
675    // Mark observer as visible
676    visibility
677        .set_pixel(config.observer_x, config.observer_y, 1.0)
678        .map_err(AlgorithmError::Core)?;
679
680    // Determine the maximum extent in cells
681    let max_cells = if let Some(max_dist) = config.max_distance {
682        (max_dist / config.cell_size).ceil() as i64
683    } else {
684        (width.max(height)) as i64
685    };
686
687    // Number of radial lines: use perimeter of bounding circle
688    let num_rays = (2.0 * core::f64::consts::PI * max_cells as f64).ceil() as usize;
689    let num_rays = num_rays.max(360);
690
691    for ray_idx in 0..num_rays {
692        let angle = 2.0 * core::f64::consts::PI * ray_idx as f64 / num_rays as f64;
693        let cos_a = angle.cos();
694        let sin_a = angle.sin();
695
696        let mut max_elev_angle = f64::NEG_INFINITY;
697
698        // Step outward along the ray
699        for step in 1..=max_cells {
700            let fx = config.observer_x as f64 + step as f64 * cos_a;
701            let fy = config.observer_y as f64 + step as f64 * sin_a;
702
703            let ix = fx.round() as i64;
704            let iy = fy.round() as i64;
705
706            if ix < 0 || ix >= width as i64 || iy < 0 || iy >= height as i64 {
707                break;
708            }
709
710            let ix_u = ix as u64;
711            let iy_u = iy as u64;
712
713            let dist = step as f64 * config.cell_size;
714
715            if let Some(max_dist) = config.max_distance {
716                if dist > max_dist {
717                    break;
718                }
719            }
720
721            let mut terrain_elev =
722                dem.get_pixel(ix_u, iy_u).map_err(AlgorithmError::Core)? + config.target_height;
723
724            // Curvature correction
725            if let Some(ref curv) = config.curvature_correction {
726                terrain_elev -= earth_curvature_offset(dist, curv);
727            }
728
729            let elev_angle = (terrain_elev - observer_elev).atan2(dist);
730
731            if elev_angle >= max_elev_angle {
732                visibility
733                    .set_pixel(ix_u, iy_u, 1.0)
734                    .map_err(AlgorithmError::Core)?;
735                max_elev_angle = elev_angle;
736            }
737        }
738    }
739
740    Ok(ViewshedResult {
741        visibility,
742        elevation_angle: None,
743    })
744}
745
746// ===========================================================================
747// R3: Sweep line with angular sorting
748// ===========================================================================
749
750/// R3 algorithm: angular sweep line approach
751///
752/// Processes cells in order of angle from the observer. For cells at the same
753/// angle, processes nearer cells first. Uses maximum angle tracking similar to
754/// R2 but processes in angular order rather than along fixed rays.
755fn viewshed_r3(dem: &RasterBuffer, config: &ViewshedConfig) -> Result<ViewshedResult> {
756    let width = dem.width();
757    let height = dem.height();
758    let mut visibility = RasterBuffer::zeros(width, height, RasterDataType::UInt8);
759
760    let observer_elev = dem
761        .get_pixel(config.observer_x, config.observer_y)
762        .map_err(AlgorithmError::Core)?
763        + config.observer_height;
764
765    visibility
766        .set_pixel(config.observer_x, config.observer_y, 1.0)
767        .map_err(AlgorithmError::Core)?;
768
769    // Build a list of all cells with their angle and distance from observer
770    let mut cells: Vec<(u64, u64, f64, f64)> = Vec::new(); // (x, y, angle, distance)
771
772    let max_dist_sq = config.max_distance.map(|d| d * d);
773
774    for y in 0..height {
775        for x in 0..width {
776            if x == config.observer_x && y == config.observer_y {
777                continue;
778            }
779
780            let dx = (x as f64 - config.observer_x as f64) * config.cell_size;
781            let dy = (y as f64 - config.observer_y as f64) * config.cell_size;
782            let dist_sq = dx * dx + dy * dy;
783
784            if let Some(max_sq) = max_dist_sq {
785                if dist_sq > max_sq {
786                    continue;
787                }
788            }
789
790            let angle = dy.atan2(dx);
791            let distance = dist_sq.sqrt();
792            cells.push((x, y, angle, distance));
793        }
794    }
795
796    // Sort by angle, then by distance (nearest first)
797    cells.sort_by(|a, b| {
798        a.2.partial_cmp(&b.2)
799            .unwrap_or(core::cmp::Ordering::Equal)
800            .then_with(|| a.3.partial_cmp(&b.3).unwrap_or(core::cmp::Ordering::Equal))
801    });
802
803    // Process cells in angular sweep order
804    // For each angular sector, maintain the maximum elevation angle
805    let num_sectors = 3600usize; // 0.1 degree resolution
806    let mut sector_max_angle = vec![f64::NEG_INFINITY; num_sectors];
807
808    for (x, y, angle, distance) in &cells {
809        let sector_idx = (((angle + core::f64::consts::PI) / (2.0 * core::f64::consts::PI)
810            * num_sectors as f64)
811            .floor() as usize)
812            .min(num_sectors - 1);
813
814        let mut terrain_elev =
815            dem.get_pixel(*x, *y).map_err(AlgorithmError::Core)? + config.target_height;
816
817        if let Some(ref curv) = config.curvature_correction {
818            terrain_elev -= earth_curvature_offset(*distance, curv);
819        }
820
821        let elev_angle = (terrain_elev - observer_elev).atan2(*distance);
822
823        if elev_angle >= sector_max_angle[sector_idx] {
824            visibility
825                .set_pixel(*x, *y, 1.0)
826                .map_err(AlgorithmError::Core)?;
827            sector_max_angle[sector_idx] = elev_angle;
828        }
829    }
830
831    Ok(ViewshedResult {
832        visibility,
833        elevation_angle: None,
834    })
835}
836
837// ===========================================================================
838// Earth curvature correction
839// ===========================================================================
840
841/// Computes the combined elevation offset due to Earth curvature and atmospheric refraction.
842///
843/// `offset = distance² / (2 · R_eff)`
844///
845/// where `R_eff = R / (1 − k)`, `R` = Earth radius (default [`EARTH_RADIUS_M`] = 6 371 000 m),
846/// and `k` = refraction coefficient (default [`REFRACTION_COEFF`] = 0.13).
847///
848/// Positive offset means the terrain at `distance` appears *lower* than on a flat Earth
849/// (i.e., the visible horizon is further away when refraction is included).  The correction
850/// is subtracted from the terrain elevation before computing elevation angles.
851fn earth_curvature_offset(distance: f64, correction: &CurvatureCorrection) -> f64 {
852    let effective_radius = correction.earth_radius / (1.0 - correction.refraction_coefficient);
853    (distance * distance) / (2.0 * effective_radius)
854}
855
856// ===========================================================================
857// Tests
858// ===========================================================================
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863
864    fn create_flat_dem(size: u64) -> RasterBuffer {
865        RasterBuffer::zeros(size, size, RasterDataType::Float32)
866    }
867
868    fn create_hill_dem() -> RasterBuffer {
869        let mut dem = RasterBuffer::zeros(20, 20, RasterDataType::Float32);
870        // Create a hill at (10, 10)
871        for y in 0..20 {
872            for x in 0..20 {
873                let dx = x as f64 - 10.0;
874                let dy = y as f64 - 10.0;
875                let dist = (dx * dx + dy * dy).sqrt();
876                let elev = (5.0 - dist).max(0.0);
877                let _ = dem.set_pixel(x, y, elev);
878            }
879        }
880        dem
881    }
882
883    fn create_wall_dem() -> RasterBuffer {
884        let mut dem = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
885        // Create a wall at y=6
886        for x in 0..10 {
887            let _ = dem.set_pixel(x, 6, 50.0);
888        }
889        dem
890    }
891
892    // --- Basic viewshed tests ---
893
894    #[test]
895    fn test_viewshed_flat_terrain_r1() {
896        let dem = create_flat_dem(10);
897        let result = compute_viewshed(&dem, 5, 5, 10.0, 0.0, None, 1.0);
898        assert!(result.is_ok());
899        let viewshed = result.expect("viewshed");
900
901        // All cells should be visible on flat terrain with elevated observer
902        for y in 0..10 {
903            for x in 0..10 {
904                let val = viewshed.get_pixel(x, y).expect("pixel");
905                assert!(
906                    val > 0.0,
907                    "Cell ({x},{y}) should be visible on flat terrain"
908                );
909            }
910        }
911    }
912
913    #[test]
914    fn test_viewshed_flat_terrain_r2() {
915        let dem = create_flat_dem(10);
916        let config = ViewshedConfig {
917            observer_x: 5,
918            observer_y: 5,
919            observer_height: 10.0,
920            target_height: 0.0,
921            max_distance: None,
922            cell_size: 1.0,
923            algorithm: ViewshedAlgorithm::R2ReferencePlane,
924            curvature_correction: None,
925        };
926        let result = compute_viewshed_advanced(&dem, &config);
927        assert!(result.is_ok());
928    }
929
930    #[test]
931    fn test_viewshed_flat_terrain_r3() {
932        let dem = create_flat_dem(10);
933        let config = ViewshedConfig {
934            observer_x: 5,
935            observer_y: 5,
936            observer_height: 10.0,
937            target_height: 0.0,
938            max_distance: None,
939            cell_size: 1.0,
940            algorithm: ViewshedAlgorithm::R3SweepLine,
941            curvature_correction: None,
942        };
943        let result = compute_viewshed_advanced(&dem, &config);
944        assert!(result.is_ok());
945    }
946
947    // --- Obstacle tests ---
948
949    #[test]
950    fn test_viewshed_with_wall() {
951        let dem = create_wall_dem();
952        let viewshed = compute_viewshed(&dem, 5, 5, 1.0, 0.0, None, 1.0).expect("viewshed");
953
954        // Observer should be visible
955        let obs = viewshed.get_pixel(5, 5).expect("obs");
956        assert!(obs > 0.0);
957
958        // Cells behind the wall should be hidden
959        let behind = viewshed.get_pixel(5, 8).expect("behind");
960        assert!(
961            behind < 0.5,
962            "Cell behind wall should not be visible, got {behind}"
963        );
964    }
965
966    // --- Distance limit ---
967
968    #[test]
969    fn test_viewshed_max_distance() {
970        let dem = create_flat_dem(20);
971        let viewshed = compute_viewshed(&dem, 10, 10, 10.0, 0.0, Some(5.0), 1.0).expect("viewshed");
972
973        // Cells within radius should be visible
974        let near = viewshed.get_pixel(10, 12).expect("near");
975        assert!(near > 0.0);
976
977        // Cells beyond radius should not be visible
978        let far = viewshed.get_pixel(0, 0).expect("far");
979        assert!(
980            far < 0.5,
981            "Cell beyond max distance should not be visible, got {far}"
982        );
983    }
984
985    // --- Earth curvature ---
986
987    #[test]
988    fn test_earth_curvature_offset() {
989        let correction = CurvatureCorrection::default();
990
991        // At 1km, the offset should be very small
992        let offset_1km = earth_curvature_offset(1000.0, &correction);
993        assert!(offset_1km > 0.0 && offset_1km < 0.1);
994
995        // At 10km, offset should be larger
996        let offset_10km = earth_curvature_offset(10_000.0, &correction);
997        assert!(offset_10km > offset_1km);
998
999        // Approximately: offset ~= d^2 / (2*R) for small d
1000        // At 10km: ~10000^2 / (2*6371000) ~= 7.85m (without refraction)
1001        // With refraction (k=0.13): effective R = 6371000/0.87 = 7322988
1002        // offset ~= 10000^2 / (2*7322988) ~= 6.83m
1003        assert!(offset_10km > 5.0 && offset_10km < 10.0);
1004    }
1005
1006    #[test]
1007    fn test_viewshed_with_curvature() {
1008        let dem = create_flat_dem(10);
1009        let config = ViewshedConfig {
1010            observer_x: 5,
1011            observer_y: 5,
1012            observer_height: 10.0,
1013            target_height: 0.0,
1014            max_distance: None,
1015            cell_size: 1.0,
1016            algorithm: ViewshedAlgorithm::R1LineOfSight,
1017            curvature_correction: Some(CurvatureCorrection::default()),
1018        };
1019        let result = compute_viewshed_advanced(&dem, &config);
1020        assert!(result.is_ok());
1021    }
1022
1023    // --- Cumulative viewshed ---
1024
1025    #[test]
1026    fn test_cumulative_viewshed() {
1027        let dem = create_flat_dem(10);
1028        let observers = vec![(2, 2, 10.0), (7, 7, 10.0)];
1029
1030        let cumulative =
1031            compute_cumulative_viewshed(&dem, &observers, 0.0, None, 1.0).expect("cumulative");
1032
1033        let center = cumulative.get_pixel(5, 5).expect("center");
1034        assert!(
1035            center >= 2.0,
1036            "Center should be visible from both observers"
1037        );
1038    }
1039
1040    #[test]
1041    fn test_cumulative_viewshed_advanced() {
1042        let dem = create_flat_dem(10);
1043        let observers = vec![
1044            ObserverPoint {
1045                x: 2,
1046                y: 2,
1047                height: 10.0,
1048            },
1049            ObserverPoint {
1050                x: 7,
1051                y: 7,
1052                height: 10.0,
1053            },
1054        ];
1055
1056        let result = compute_cumulative_viewshed_advanced(
1057            &dem,
1058            &observers,
1059            0.0,
1060            None,
1061            1.0,
1062            Some(CurvatureCorrection::default()),
1063        );
1064        assert!(result.is_ok());
1065    }
1066
1067    // --- LOS profile ---
1068
1069    #[test]
1070    fn test_los_profile() {
1071        let dem = create_flat_dem(10);
1072        let profile = compute_los_profile(&dem, 0, 0, 10.0, 9, 9, 1.0, None);
1073        assert!(profile.is_ok());
1074        let prof = profile.expect("profile");
1075        assert!(prof.len() >= 2);
1076
1077        // First point should be at distance 0
1078        assert!(prof[0].0.abs() < 1e-6);
1079    }
1080
1081    #[test]
1082    fn test_los_profile_with_curvature() {
1083        let dem = create_flat_dem(10);
1084        let profile = compute_los_profile(
1085            &dem,
1086            0,
1087            0,
1088            10.0,
1089            9,
1090            9,
1091            1.0,
1092            Some(CurvatureCorrection::default()),
1093        );
1094        assert!(profile.is_ok());
1095    }
1096
1097    // --- Fresnel zone ---
1098
1099    #[test]
1100    fn test_fresnel_clearance_flat() {
1101        let dem = create_flat_dem(10);
1102        let clearance = compute_fresnel_clearance(&dem, 0, 0, 10.0, 9, 9, 10.0, 1.0, 2.4, None);
1103        assert!(clearance.is_ok());
1104        let c = clearance.expect("clearance");
1105        // Both antennas at 10m on flat terrain should have good clearance
1106        assert!(c > 0.0, "Fresnel clearance should be positive, got {c}");
1107    }
1108
1109    #[test]
1110    fn test_fresnel_clearance_with_wall() {
1111        let dem = create_wall_dem();
1112        let clearance = compute_fresnel_clearance(&dem, 5, 3, 2.0, 5, 9, 2.0, 1.0, 2.4, None);
1113        assert!(clearance.is_ok());
1114        let c = clearance.expect("clearance");
1115        // Wall at y=6 should block the path
1116        assert!(
1117            c < 0.0,
1118            "Fresnel clearance should be negative with wall, got {c}"
1119        );
1120    }
1121
1122    // --- Invalid inputs ---
1123
1124    #[test]
1125    fn test_viewshed_invalid_observer() {
1126        let dem = create_flat_dem(10);
1127        let config = ViewshedConfig {
1128            observer_x: 100,
1129            observer_y: 100,
1130            observer_height: 10.0,
1131            target_height: 0.0,
1132            max_distance: None,
1133            cell_size: 1.0,
1134            algorithm: ViewshedAlgorithm::R1LineOfSight,
1135            curvature_correction: None,
1136        };
1137        let result = compute_viewshed_advanced(&dem, &config);
1138        assert!(result.is_err());
1139    }
1140
1141    // --- ViewshedResult elevation angle ---
1142
1143    #[test]
1144    fn test_viewshed_elevation_angle() {
1145        let dem = create_flat_dem(10);
1146        let config = ViewshedConfig {
1147            observer_x: 5,
1148            observer_y: 5,
1149            observer_height: 10.0,
1150            target_height: 0.0,
1151            max_distance: None,
1152            cell_size: 1.0,
1153            algorithm: ViewshedAlgorithm::R1LineOfSight,
1154            curvature_correction: None,
1155        };
1156        let result = compute_viewshed_advanced(&dem, &config).expect("result");
1157        assert!(result.elevation_angle.is_some());
1158
1159        let elev_angle = result.elevation_angle.expect("elev_angle");
1160        let angle = elev_angle.get_pixel(5, 8).expect("angle");
1161        // Looking down from 10m height to ground 3 cells away
1162        // angle = atan(-10/3) ~= -1.28 rad (negative = looking down)
1163        assert!(
1164            angle < 0.0,
1165            "Elevation angle should be negative (looking down), got {angle}"
1166        );
1167    }
1168}