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