Apply 5-point circular moving average to reach_km values before rendering the boundary polygon. Eliminates sharp spikes caused by isolated terrain anomalies in SRTM data.
218 lines
7.4 KiB
Elixir
218 lines
7.4 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 effective reach accounting for both terrain and propagation conditions.
|
||
Higher propagation scores indicate ducting potential, which lets signals
|
||
propagate beyond terrain obstructions via atmospheric waveguide.
|
||
"""
|
||
def effective_reach_km(analysis, max_range_km, score) do
|
||
case analysis.verdict do
|
||
"CLEAR" ->
|
||
max_range_km
|
||
|
||
"FRESNEL_MINOR" ->
|
||
max_range_km * 0.9
|
||
|
||
"FRESNEL_PARTIAL" ->
|
||
max_range_km * 0.7
|
||
|
||
"BLOCKED" ->
|
||
terrain_factor = terrain_reach_factor(analysis.diffraction_db)
|
||
ducting_factor = ducting_reach_factor(score)
|
||
max_range_km * max(terrain_factor, ducting_factor)
|
||
end
|
||
end
|
||
|
||
# Convert diffraction loss to a range reduction factor (0.0–1.0).
|
||
# Mild diffraction still allows significant propagation;
|
||
# heavy blockage attenuates sharply.
|
||
defp terrain_reach_factor(db) when db <= 3, do: 0.8
|
||
defp terrain_reach_factor(db) when db <= 6, do: 0.5
|
||
defp terrain_reach_factor(db) when db <= 12, do: 0.3
|
||
defp terrain_reach_factor(db) when db <= 20, do: 0.15
|
||
defp terrain_reach_factor(_db), do: 0.05
|
||
|
||
# Higher propagation scores mean stronger ducting potential —
|
||
# signals can ride atmospheric layers over terrain obstacles.
|
||
# Returns a floor factor so blocked paths still get range
|
||
# proportional to atmospheric conditions.
|
||
defp ducting_reach_factor(score) when score >= 80, do: 0.7
|
||
defp ducting_reach_factor(score) when score >= 65, do: 0.5
|
||
defp ducting_reach_factor(score) when score >= 50, do: 0.3
|
||
defp ducting_reach_factor(score) when score >= 33, do: 0.15
|
||
defp ducting_reach_factor(_score), do: 0.05
|
||
|
||
@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)
|
||
score = Keyword.get(opts, :score, 50)
|
||
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, score, 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)
|
||
|> smooth_boundary(lat, lon)
|
||
|
||
%{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: ant_ht_a_m, ant_ht_b: 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, score, tiles_dir) do
|
||
# Check terrain within the radio horizon where terrain features matter.
|
||
# Beyond this, propagation is atmospheric (ducting/scatter) and terrain
|
||
# doesn't block — earth curvature is handled by the atmosphere.
|
||
terrain_check_km = min(radio_horizon_km(ant_height_m) * 2.0, max_range_km)
|
||
{end_lat, end_lon} = destination_point(origin_lat, origin_lon, bearing, terrain_check_km)
|
||
|
||
case Srtm.fetch_elevation_profile(origin_lat, origin_lon, end_lat, end_lon, tiles_dir) do
|
||
{:ok, profile} ->
|
||
analysis =
|
||
TerrainAnalysis.analyse(profile, terrain_check_km, freq_ghz, ant_ht_a: ant_height_m, ant_ht_b: ant_height_m)
|
||
|
||
reach_km = effective_reach_km(analysis, max_range_km, score)
|
||
{reach_lat, reach_lon} = destination_point(origin_lat, origin_lon, bearing, reach_km)
|
||
|
||
%{
|
||
bearing: bearing,
|
||
reach_km: reach_km,
|
||
lat: reach_lat,
|
||
lon: reach_lon
|
||
}
|
||
|
||
{:error, _} ->
|
||
{end_lat2, end_lon2} = destination_point(origin_lat, origin_lon, bearing, max_range_km)
|
||
%{bearing: bearing, reach_km: max_range_km, lat: end_lat2, lon: end_lon2}
|
||
end
|
||
end
|
||
|
||
# Radio horizon distance in km for a given antenna height (K=4/3 atmosphere)
|
||
defp radio_horizon_km(height_m) do
|
||
:math.sqrt(2 * (4 / 3) * @earth_radius_km * 1000 * height_m) / 1000
|
||
end
|
||
|
||
defp srtm_tiles_dir do
|
||
Application.get_env(:microwaveprop, :srtm_tiles_dir, Path.expand("~/srtm/tiles"))
|
||
end
|
||
|
||
# Smooth reach_km values with a circular moving average to remove spikes
|
||
# from SRTM elevation artifacts, then recompute boundary lat/lon.
|
||
defp smooth_boundary(points, _origin_lat, _origin_lon) when length(points) < 5, do: points
|
||
|
||
defp smooth_boundary(points, origin_lat, origin_lon) do
|
||
reaches = Enum.map(points, & &1.reach_km)
|
||
n = length(reaches)
|
||
arr = :array.from_list(reaches)
|
||
half = 2
|
||
|
||
smoothed_reaches =
|
||
Enum.map(0..(n - 1), fn i ->
|
||
window =
|
||
for offset <- -half..half do
|
||
:array.get(rem(i + offset + n, n), arr)
|
||
end
|
||
|
||
Enum.sum(window) / length(window)
|
||
end)
|
||
|
||
points
|
||
|> Enum.zip(smoothed_reaches)
|
||
|> Enum.map(fn {pt, reach} ->
|
||
{lat, lon} = destination_point(origin_lat, origin_lon, pt.bearing, reach)
|
||
%{pt | reach_km: reach, lat: lat, lon: lon}
|
||
end)
|
||
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
|