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
This commit is contained in:
parent
166f95e29d
commit
5eaa55448e
5 changed files with 382 additions and 55 deletions
|
|
@ -264,11 +264,26 @@ export const PropagationMap = {
|
|||
}
|
||||
})
|
||||
|
||||
// Click on map to request factor detail from server
|
||||
// Click on map to request viewshed + factor detail from server
|
||||
this.map.on("click", (e) => {
|
||||
this.rangeCircles.clearLayers()
|
||||
|
||||
// Show a temporary marker while computing
|
||||
L.circleMarker([e.latlng.lat, e.latlng.lng], {
|
||||
radius: 6,
|
||||
color: "#fff",
|
||||
weight: 2,
|
||||
fillColor: "#888",
|
||||
fillOpacity: 0.8,
|
||||
interactive: false
|
||||
}).addTo(this.rangeCircles)
|
||||
|
||||
// Always request terrain viewshed
|
||||
this.pushEvent("compute_viewshed", { lat: e.latlng.lat, lon: e.latlng.lng })
|
||||
|
||||
// Also request propagation detail if grid data exists
|
||||
const basic = this.lookupPoint(e.latlng.lat, e.latlng.lng)
|
||||
if (basic) {
|
||||
this.showRangeCircles(basic)
|
||||
this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng })
|
||||
}
|
||||
})
|
||||
|
|
@ -283,6 +298,37 @@ export const PropagationMap = {
|
|||
}
|
||||
})
|
||||
|
||||
this.handleEvent("viewshed_result", ({ origin, points }) => {
|
||||
this.rangeCircles.clearLayers()
|
||||
|
||||
if (points && points.length > 0) {
|
||||
const latlngs = points.map(p => [p.lat, p.lon])
|
||||
|
||||
L.polygon(latlngs, {
|
||||
color: "#222",
|
||||
weight: 2,
|
||||
opacity: 0.8,
|
||||
fillColor: "#000",
|
||||
fillOpacity: 0.12,
|
||||
interactive: false,
|
||||
smoothFactor: 1
|
||||
}).addTo(this.rangeCircles)
|
||||
|
||||
// Center marker colored by score tier if available
|
||||
const basic = this.lookupPoint(origin.lat, origin.lon)
|
||||
const markerColor = basic ? scoreTier(basic.score).color : "#888"
|
||||
|
||||
L.circleMarker([origin.lat, origin.lon], {
|
||||
radius: 6,
|
||||
color: "#fff",
|
||||
weight: 2,
|
||||
fillColor: markerColor,
|
||||
fillOpacity: 1,
|
||||
interactive: false
|
||||
}).addTo(this.rangeCircles)
|
||||
}
|
||||
})
|
||||
|
||||
this.map.on("popupclose", () => this.rangeCircles.clearLayers())
|
||||
|
||||
// Close popup on double-click so zoom works through it
|
||||
|
|
@ -442,54 +488,6 @@ export const PropagationMap = {
|
|||
return { ...data, ...this.bandInfo }
|
||||
},
|
||||
|
||||
showRangeCircles(detail) {
|
||||
this.rangeCircles.clearLayers()
|
||||
const center = [detail.lat, detail.lon]
|
||||
const score = detail.score
|
||||
const tier = scoreTier(score)
|
||||
|
||||
let rangeKm
|
||||
if (score >= 80) rangeKm = detail.exceptional_range_km
|
||||
else if (score >= 65) rangeKm = detail.extended_range_km
|
||||
else if (score >= 50) rangeKm = detail.typical_range_km
|
||||
else if (score >= 33) rangeKm = Math.round(detail.typical_range_km * 0.6)
|
||||
else rangeKm = Math.round(detail.typical_range_km * 0.3)
|
||||
|
||||
const typicalKm = detail.typical_range_km
|
||||
|
||||
L.circle(center, {
|
||||
radius: rangeKm * 1000,
|
||||
color: "#222",
|
||||
weight: 3,
|
||||
opacity: 0.9,
|
||||
fillColor: "#000",
|
||||
fillOpacity: 0.2,
|
||||
dashArray: "8,6",
|
||||
interactive: false
|
||||
}).addTo(this.rangeCircles)
|
||||
|
||||
if (typicalKm < rangeKm) {
|
||||
L.circle(center, {
|
||||
radius: typicalKm * 1000,
|
||||
color: "#222",
|
||||
weight: 3,
|
||||
opacity: 0.9,
|
||||
fillColor: "#000",
|
||||
fillOpacity: 0.25,
|
||||
interactive: false
|
||||
}).addTo(this.rangeCircles)
|
||||
}
|
||||
|
||||
L.circleMarker(center, {
|
||||
radius: 6,
|
||||
color: "#fff",
|
||||
weight: 2,
|
||||
fillColor: tier.color,
|
||||
fillOpacity: 1,
|
||||
interactive: false
|
||||
}).addTo(this.rangeCircles)
|
||||
},
|
||||
|
||||
sendBounds() {
|
||||
topbar.show()
|
||||
const b = this.map.getBounds()
|
||||
|
|
|
|||
133
lib/microwaveprop/terrain/viewshed.ex
Normal file
133
lib/microwaveprop/terrain/viewshed.ex
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
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
|
||||
|
|
@ -4,6 +4,7 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
|
||||
alias Microwaveprop.Propagation
|
||||
alias Microwaveprop.Propagation.BandConfig
|
||||
alias Microwaveprop.Terrain.Viewshed
|
||||
|
||||
@default_band 10_000
|
||||
@initial_bounds %{
|
||||
|
|
@ -31,7 +32,8 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
initial_scores_json: Jason.encode!(initial_scores),
|
||||
valid_time: valid_time,
|
||||
bounds: @initial_bounds,
|
||||
grid_visible: false
|
||||
grid_visible: false,
|
||||
antenna_height_ft: 8
|
||||
)}
|
||||
end
|
||||
|
||||
|
|
@ -49,6 +51,16 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("set_antenna_height", %{"height_ft" => value}, socket) do
|
||||
height =
|
||||
case Integer.parse(value) do
|
||||
{h, _} when h >= 0 and h <= 200 -> h
|
||||
_ -> 8
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :antenna_height_ft, height)}
|
||||
end
|
||||
|
||||
def handle_event("toggle_grid", _params, socket) do
|
||||
visible = !socket.assigns.grid_visible
|
||||
|
||||
|
|
@ -76,6 +88,24 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("compute_viewshed", %{"lat" => lat, "lon" => lon}, socket) do
|
||||
band_config = BandConfig.get(socket.assigns.selected_band)
|
||||
freq_ghz = socket.assigns.selected_band / 1_000
|
||||
ant_height_m = socket.assigns.antenna_height_ft * 0.3048
|
||||
max_range_km = min(band_config.typical_range_km, 100)
|
||||
|
||||
socket =
|
||||
start_async(socket, :viewshed, fn ->
|
||||
Viewshed.compute(lat, lon,
|
||||
freq_ghz: freq_ghz,
|
||||
ant_height_m: ant_height_m,
|
||||
max_range_km: max_range_km
|
||||
)
|
||||
end)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:propagation_updated, valid_time}, socket) do
|
||||
scores = Propagation.latest_scores(socket.assigns.selected_band, socket.assigns.bounds)
|
||||
|
|
@ -88,6 +118,23 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_async(:viewshed, {:ok, result}, socket) do
|
||||
points = Enum.map(result.boundary, fn p -> %{lat: p.lat, lon: p.lon} end)
|
||||
|
||||
socket =
|
||||
push_event(socket, "viewshed_result", %{
|
||||
origin: result.origin,
|
||||
points: points
|
||||
})
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_async(:viewshed, {:exit, _reason}, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp band_info(band_mhz) do
|
||||
config = BandConfig.get(band_mhz)
|
||||
|
||||
|
|
@ -143,10 +190,7 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
<%!-- Band selector --%>
|
||||
<div class="dropdown">
|
||||
<div tabindex="0" role="button" class="btn btn-sm w-full justify-between">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<.icon name="hero-signal" class="size-4" />
|
||||
{selected_label(@bands, @selected_band)}
|
||||
</span>
|
||||
{selected_label(@bands, @selected_band)}
|
||||
<.icon name="hero-chevron-down" class="size-3" />
|
||||
</div>
|
||||
<ul
|
||||
|
|
@ -179,6 +223,21 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
<span class="text-sm">Grid squares</span>
|
||||
</label>
|
||||
|
||||
<%!-- Antenna height --%>
|
||||
<form phx-change="set_antenna_height" class="flex items-center gap-2 px-1">
|
||||
<label class="text-sm whitespace-nowrap">Antenna</label>
|
||||
<input
|
||||
type="number"
|
||||
name="height_ft"
|
||||
min="0"
|
||||
max="200"
|
||||
step="1"
|
||||
value={@antenna_height_ft}
|
||||
class="input input-xs w-16 text-right"
|
||||
/>
|
||||
<span class="text-xs opacity-70">ft</span>
|
||||
</form>
|
||||
|
||||
<%!-- Latest data --%>
|
||||
<div :if={@valid_time} class="text-xs opacity-70 px-1">
|
||||
Data: {Calendar.strftime(@valid_time, "%b %d, %H:%M UTC")} ({time_ago(@valid_time)})
|
||||
|
|
|
|||
110
test/microwaveprop/terrain/viewshed_test.exs
Normal file
110
test/microwaveprop/terrain/viewshed_test.exs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
defmodule Microwaveprop.Terrain.ViewshedTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Microwaveprop.Terrain.Viewshed
|
||||
|
||||
describe "destination_point/4" do
|
||||
test "north bearing increases latitude, holds longitude" do
|
||||
{lat, lon} = Viewshed.destination_point(32.0, -97.0, 0, 100.0)
|
||||
assert_in_delta lat, 32.899, 0.01
|
||||
assert_in_delta lon, -97.0, 0.01
|
||||
end
|
||||
|
||||
test "east bearing increases longitude, holds latitude" do
|
||||
{lat, lon} = Viewshed.destination_point(32.0, -97.0, 90, 100.0)
|
||||
assert_in_delta lat, 32.0, 0.01
|
||||
assert lon > -97.0
|
||||
end
|
||||
|
||||
test "south bearing decreases latitude" do
|
||||
{lat, lon} = Viewshed.destination_point(32.0, -97.0, 180, 50.0)
|
||||
assert lat < 32.0
|
||||
assert_in_delta lon, -97.0, 0.01
|
||||
end
|
||||
|
||||
test "zero distance returns origin" do
|
||||
{lat, lon} = Viewshed.destination_point(32.0, -97.0, 45, 0.0)
|
||||
assert_in_delta lat, 32.0, 0.001
|
||||
assert_in_delta lon, -97.0, 0.001
|
||||
end
|
||||
end
|
||||
|
||||
describe "find_reach_km/2" do
|
||||
test "returns max range when no points are obstructed" do
|
||||
points = [
|
||||
%{obstructed: false, dist_km: 0.0},
|
||||
%{obstructed: false, dist_km: 10.0},
|
||||
%{obstructed: false, dist_km: 20.0},
|
||||
%{obstructed: false, dist_km: 30.0},
|
||||
%{obstructed: false, dist_km: 50.0}
|
||||
]
|
||||
|
||||
assert Viewshed.find_reach_km(points, 50.0) == 50.0
|
||||
end
|
||||
|
||||
test "returns distance of point before first obstruction" do
|
||||
points = [
|
||||
%{obstructed: false, dist_km: 0.0},
|
||||
%{obstructed: false, dist_km: 10.0},
|
||||
%{obstructed: false, dist_km: 20.0},
|
||||
%{obstructed: true, dist_km: 30.0},
|
||||
%{obstructed: false, dist_km: 40.0},
|
||||
%{obstructed: false, dist_km: 50.0}
|
||||
]
|
||||
|
||||
assert Viewshed.find_reach_km(points, 50.0) == 20.0
|
||||
end
|
||||
|
||||
test "returns first interior point distance when obstruction is at second point" do
|
||||
points = [
|
||||
%{obstructed: false, dist_km: 0.0},
|
||||
%{obstructed: true, dist_km: 5.0},
|
||||
%{obstructed: false, dist_km: 10.0}
|
||||
]
|
||||
|
||||
# First point is endpoint (excluded), second is first interior and is obstructed
|
||||
# No clear interior point before it, return minimum
|
||||
assert Viewshed.find_reach_km(points, 10.0) == 0.0
|
||||
end
|
||||
|
||||
test "ignores endpoint obstruction flags" do
|
||||
# Endpoints (first/last) are never counted as obstructed by TerrainAnalysis
|
||||
# but just to be safe, find_reach_km skips them
|
||||
points = [
|
||||
%{obstructed: true, dist_km: 0.0},
|
||||
%{obstructed: false, dist_km: 25.0},
|
||||
%{obstructed: true, dist_km: 50.0}
|
||||
]
|
||||
|
||||
assert Viewshed.find_reach_km(points, 50.0) == 50.0
|
||||
end
|
||||
end
|
||||
|
||||
describe "analyse_ray/5" do
|
||||
test "returns full range for flat terrain with antenna heights" do
|
||||
profile =
|
||||
for i <- 0..10 do
|
||||
f = i / 10
|
||||
%{lat: 32.9 + f * 0.09, lon: -97.0, d: f, elev: 200.0, dist_km: f * 10.0}
|
||||
end
|
||||
|
||||
result = Viewshed.analyse_ray(profile, 10.0, 10.0, 2.4, 2.4)
|
||||
assert result.reach_km == 10.0
|
||||
end
|
||||
|
||||
test "detects obstruction and returns reduced reach" do
|
||||
# Use 10km total so earth bulge is negligible (~0.7m) and
|
||||
# the 500m peak at index 5 is the only obstruction.
|
||||
profile =
|
||||
for i <- 0..10 do
|
||||
f = i / 10
|
||||
elev = if i == 5, do: 500.0, else: 200.0
|
||||
%{lat: 32.9 + f * 0.009, lon: -97.0, d: f, elev: elev, dist_km: f * 10.0}
|
||||
end
|
||||
|
||||
result = Viewshed.analyse_ray(profile, 10.0, 10.0, 2.4, 2.4)
|
||||
assert result.reach_km < 10.0
|
||||
assert result.reach_km > 0.0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -58,6 +58,33 @@ defmodule MicrowavepropWeb.MapLiveTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "antenna height" do
|
||||
test "renders antenna height input with default 8 ft", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/map")
|
||||
assert html =~ ~s(name="height_ft")
|
||||
assert html =~ ~s(value="8")
|
||||
assert html =~ "ft"
|
||||
end
|
||||
|
||||
test "updates antenna height on change", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/map")
|
||||
|
||||
html = render_change(lv, "set_antenna_height", %{"height_ft" => "20"})
|
||||
assert html =~ ~s(value="20")
|
||||
end
|
||||
end
|
||||
|
||||
describe "compute_viewshed" do
|
||||
test "accepts compute_viewshed event without crashing", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/map")
|
||||
# Event should be accepted; async computation may fail (no SRTM in test)
|
||||
# but the LiveView should not crash
|
||||
render_click(lv, "compute_viewshed", %{"lat" => 32.9, "lon" => -97.0})
|
||||
# If we get here without crash, the event handler exists
|
||||
assert render(lv) =~ "propagation-map"
|
||||
end
|
||||
end
|
||||
|
||||
describe "toggle_grid" do
|
||||
test "toggles grid visibility", %{conn: conn} do
|
||||
{:ok, lv, html} = live(conn, ~p"/map")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue