defmodule Microwaveprop.Terrain.ViewshedPropertyTest do @moduledoc """ StreamData property tests for the pure-math portions of `Microwaveprop.Terrain.Viewshed`. Each property exercises one physical invariant of the viewshed's math helpers without touching SRTM tiles or `Task.async_stream` — the scenarios are constructed so the expected bound is physically meaningful (reach never exceeds max range, flat terrain is always visible, destination_point is a no-op for zero distance, and the BLOCKED ducting-vs-terrain `max/2` is monotonic in score). """ use ExUnit.Case, async: true use ExUnitProperties alias Microwaveprop.Terrain.Viewshed describe "find_reach_km/2" do property "never returns a value greater than max_range_km" do check all( n <- integer(2..20), max_range <- float(min: 1.0, max: 500.0), obstruction_idx <- integer(0..25) ) do points = for i <- 0..n do %{obstructed: i == obstruction_idx and i > 0 and i < n, dist_km: i / n * max_range} end reach = Viewshed.find_reach_km(points, max_range) assert reach <= max_range assert reach >= 0.0 end end property "all-clear profiles always return max_range_km" do check all( n <- integer(2..30), max_range <- float(min: 0.1, max: 1_000.0) ) do points = for i <- 0..n do %{obstructed: false, dist_km: i / n * max_range} end assert Viewshed.find_reach_km(points, max_range) == max_range end end end describe "destination_point/4" do property "zero distance always returns (approximately) the origin" do check all( lat <- float(min: -80.0, max: 80.0), lon <- float(min: -180.0, max: 180.0), bearing <- float(min: 0.0, max: 359.9) ) do {lat2, lon2} = Viewshed.destination_point(lat, lon, bearing, 0.0) assert_in_delta lat2, lat, 1.0e-9 assert_in_delta lon2, lon, 1.0e-9 end end property "north/south bearings preserve longitude and move latitude in the right sign" do # `destination_point/4` is great-circle: due-east travel at non-zero # latitude slightly bends a tiny bit toward the pole/equator, so # we only assert the exact-meridian preservation for N/S bearings, # which is a pure math identity regardless of latitude. check all( lat <- float(min: -60.0, max: 60.0), lon <- float(min: -170.0, max: 170.0), dist_km <- float(min: 1.0, max: 500.0) ) do {lat_n, lon_n} = Viewshed.destination_point(lat, lon, 0.0, dist_km) {lat_s, lon_s} = Viewshed.destination_point(lat, lon, 180.0, dist_km) assert_in_delta lon_n, lon, 1.0e-6 assert_in_delta lon_s, lon, 1.0e-6 # Far enough from the poles (|lat| ≤ 60) no wraparound occurs at 500 km. assert lat_n > lat assert lat_s < lat end end end describe "effective_reach_km/3" do property "BLOCKED verdicts: reach is non-decreasing as score rises" do # terrain_reach_factor is constant in score, so max(terrain, ducting) # can only rise as ducting_reach_factor rises. ducting_reach_factor # is a non-decreasing step function of score. check all( dif_db <- float(min: 0.0, max: 60.0), max_range <- float(min: 1.0, max: 500.0), score_a <- integer(0..100), bump <- integer(0..50) ) do score_b = min(100, score_a + bump) analysis = %{verdict: "BLOCKED", diffraction_db: dif_db} r_a = Viewshed.effective_reach_km(analysis, max_range, score_a) r_b = Viewshed.effective_reach_km(analysis, max_range, score_b) assert r_b >= r_a end end property "non-BLOCKED verdicts return deterministic fractions of max_range" do check all(max_range <- float(min: 0.0, max: 1_000.0), score <- integer(0..100)) do clear = %{verdict: "CLEAR", diffraction_db: 0.0} minor = %{verdict: "FRESNEL_MINOR", diffraction_db: 1.0} partial = %{verdict: "FRESNEL_PARTIAL", diffraction_db: 4.0} assert Viewshed.effective_reach_km(clear, max_range, score) == max_range assert_in_delta Viewshed.effective_reach_km(minor, max_range, score), max_range * 0.9, 1.0e-9 assert_in_delta Viewshed.effective_reach_km(partial, max_range, score), max_range * 0.7, 1.0e-9 end end end describe "destination_point/4 round-trip distance" do property "the generated point is ~dist_km from the origin via the haversine" do # Regardless of bearing, the great-circle distance between the # origin and destination_point's result must match the requested # distance. Allow a loose absolute tolerance for earth-radius # rounding at longer distances (the module uses 6371 km exactly). check all( lat <- float(min: -60.0, max: 60.0), lon <- float(min: -170.0, max: 170.0), bearing <- float(min: 0.0, max: 359.999), dist_km <- float(min: 1.0, max: 500.0) ) do {lat2, lon2} = Viewshed.destination_point(lat, lon, bearing, dist_km) measured = Microwaveprop.Geo.haversine_km(lat, lon, lat2, lon2) assert_in_delta measured, dist_km, 0.5 end end end describe "analyse_ray/5 over generated flat profiles" do property "flat terrain with high antennas always reaches the full distance" do # Antenna floor chosen to beat the worst-case (max dist, max freq) # earth-bulge + first-Fresnel clearance with margin, matching the # pattern used in TerrainAnalysis property tests. ant_h = 150.0 check all( n_segs <- integer(4..16), dist_km <- float(min: 2.0, max: 40.0), freq_ghz <- float(min: 5.0, max: 50.0) ) do profile = for i <- 0..n_segs do f = i / n_segs %{lat: 32.9 + f * 0.1, lon: -97.0, d: f, elev: 0.0, dist_km: f * dist_km} end result = Viewshed.analyse_ray(profile, dist_km, freq_ghz, ant_h, ant_h) assert result.reach_km == dist_km assert result.verdict in ["CLEAR", "FRESNEL_MINOR"] end end end end