prop/lib/microwaveprop/terrain/viewshed.ex
Graham McIntire 5eaa55448e
Add terrain-aware viewshed on map click
Replace generic range circles with actual LOS coverage polygon computed
from SRTM elevation data. Casts 180 rays (every 2 degrees) from the
clicked point, runs Fresnel/diffraction analysis on each, and renders
the reachable area as a Leaflet polygon.

- Viewshed module with haversine forward, terrain sweep, async compute
- Antenna height control (default 8 ft) in map panel
- LiveView start_async/handle_async for non-blocking computation
- Remove signal icon from band selector
2026-03-31 13:06:01 -05:00

133 lines
4.1 KiB
Elixir

defmodule Microwaveprop.Terrain.Viewshed do
@moduledoc false
alias Microwaveprop.Terrain.Srtm
alias Microwaveprop.Terrain.TerrainAnalysis
@earth_radius_km 6371.0
@default_angular_step 2
@default_max_range_km 50
@doc "Compute destination lat/lon given origin, bearing (degrees), and distance (km)."
def destination_point(lat, lon, bearing_deg, dist_km) do
lat_rad = deg_to_rad(lat)
lon_rad = deg_to_rad(lon)
brg_rad = deg_to_rad(bearing_deg)
d = dist_km / @earth_radius_km
lat2 =
:math.asin(
:math.sin(lat_rad) * :math.cos(d) +
:math.cos(lat_rad) * :math.sin(d) * :math.cos(brg_rad)
)
lon2 =
lon_rad +
:math.atan2(
:math.sin(brg_rad) * :math.sin(d) * :math.cos(lat_rad),
:math.cos(d) - :math.sin(lat_rad) * :math.sin(lat2)
)
{rad_to_deg(lat2), rad_to_deg(lon2)}
end
@doc """
Find the max clear distance along a ray from TerrainAnalysis points.
Skips endpoints (first/last), returns the dist_km of the last clear
interior point before the first obstruction.
"""
def find_reach_km(points, max_range_km) do
interior = Enum.slice(points, 1..-2//1)
case find_first_obstructed_index(interior) do
nil ->
max_range_km
0 ->
0.0
idx ->
Enum.at(interior, idx - 1).dist_km
end
end
@doc """
Compute a terrain viewshed from a point. Returns a map with :origin
and :boundary (list of %{bearing, reach_km, lat, lon}).
Options:
- :freq_ghz — frequency for Fresnel zone calc (default 10.0)
- :max_range_km — max ray distance (default 50)
- :ant_height_m — antenna height at both ends (default 2.4)
- :angular_step — degrees between rays (default 2)
- :tiles_dir — SRTM tiles directory (default from config)
"""
def compute(lat, lon, opts \\ []) do
freq_ghz = Keyword.get(opts, :freq_ghz, 10.0)
max_range_km = Keyword.get(opts, :max_range_km, @default_max_range_km)
ant_height_m = Keyword.get(opts, :ant_height_m, 2.4)
angular_step = Keyword.get(opts, :angular_step, @default_angular_step)
tiles_dir = Keyword.get(opts, :tiles_dir, srtm_tiles_dir())
bearings = Enum.to_list(0..359//angular_step)
boundary =
bearings
|> Task.async_stream(
fn bearing ->
compute_ray(lat, lon, bearing, freq_ghz, max_range_km, ant_height_m, tiles_dir)
end,
max_concurrency: System.schedulers_online(),
timeout: 30_000,
on_timeout: :kill_task
)
|> Enum.map(fn
{:ok, result} -> result
{:exit, _} -> nil
end)
|> Enum.reject(&is_nil/1)
%{origin: %{lat: lat, lon: lon}, boundary: boundary}
end
@doc """
Analyse a single ray's profile. Public for testing.
Returns %{reach_km: float, verdict: string}.
"""
def analyse_ray(profile, dist_km, freq_ghz, ant_ht_a_m, ant_ht_b_m) do
analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz, ant_ht_a_m, ant_ht_b_m)
reach_km = find_reach_km(analysis.points, dist_km)
%{reach_km: reach_km, verdict: analysis.verdict}
end
defp compute_ray(origin_lat, origin_lon, bearing, freq_ghz, max_range_km, ant_height_m, tiles_dir) do
{end_lat, end_lon} = destination_point(origin_lat, origin_lon, bearing, max_range_km)
case Srtm.fetch_elevation_profile(origin_lat, origin_lon, end_lat, end_lon, tiles_dir) do
{:ok, profile} ->
result = analyse_ray(profile, max_range_km, freq_ghz, ant_height_m, ant_height_m)
{reach_lat, reach_lon} = destination_point(origin_lat, origin_lon, bearing, result.reach_km)
%{
bearing: bearing,
reach_km: result.reach_km,
lat: reach_lat,
lon: reach_lon
}
{:error, _} ->
%{bearing: bearing, reach_km: max_range_km, lat: end_lat, lon: end_lon}
end
end
defp srtm_tiles_dir do
Application.get_env(:microwaveprop, :srtm_tiles_dir, Path.expand("~/srtm/tiles"))
end
defp find_first_obstructed_index(interior) do
Enum.find_index(interior, & &1.obstructed)
end
defp deg_to_rad(deg), do: deg * :math.pi() / 180
defp rad_to_deg(rad), do: rad * 180 / :math.pi()
end