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.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)
|
const basic = this.lookupPoint(e.latlng.lat, e.latlng.lng)
|
||||||
if (basic) {
|
if (basic) {
|
||||||
this.showRangeCircles(basic)
|
|
||||||
this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng })
|
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())
|
this.map.on("popupclose", () => this.rangeCircles.clearLayers())
|
||||||
|
|
||||||
// Close popup on double-click so zoom works through it
|
// Close popup on double-click so zoom works through it
|
||||||
|
|
@ -442,54 +488,6 @@ export const PropagationMap = {
|
||||||
return { ...data, ...this.bandInfo }
|
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() {
|
sendBounds() {
|
||||||
topbar.show()
|
topbar.show()
|
||||||
const b = this.map.getBounds()
|
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
|
||||||
alias Microwaveprop.Propagation.BandConfig
|
alias Microwaveprop.Propagation.BandConfig
|
||||||
|
alias Microwaveprop.Terrain.Viewshed
|
||||||
|
|
||||||
@default_band 10_000
|
@default_band 10_000
|
||||||
@initial_bounds %{
|
@initial_bounds %{
|
||||||
|
|
@ -31,7 +32,8 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
initial_scores_json: Jason.encode!(initial_scores),
|
initial_scores_json: Jason.encode!(initial_scores),
|
||||||
valid_time: valid_time,
|
valid_time: valid_time,
|
||||||
bounds: @initial_bounds,
|
bounds: @initial_bounds,
|
||||||
grid_visible: false
|
grid_visible: false,
|
||||||
|
antenna_height_ft: 8
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -49,6 +51,16 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
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
|
def handle_event("toggle_grid", _params, socket) do
|
||||||
visible = !socket.assigns.grid_visible
|
visible = !socket.assigns.grid_visible
|
||||||
|
|
||||||
|
|
@ -76,6 +88,24 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
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
|
@impl true
|
||||||
def handle_info({:propagation_updated, valid_time}, socket) do
|
def handle_info({:propagation_updated, valid_time}, socket) do
|
||||||
scores = Propagation.latest_scores(socket.assigns.selected_band, socket.assigns.bounds)
|
scores = Propagation.latest_scores(socket.assigns.selected_band, socket.assigns.bounds)
|
||||||
|
|
@ -88,6 +118,23 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
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
|
defp band_info(band_mhz) do
|
||||||
config = BandConfig.get(band_mhz)
|
config = BandConfig.get(band_mhz)
|
||||||
|
|
||||||
|
|
@ -143,10 +190,7 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
<%!-- Band selector --%>
|
<%!-- Band selector --%>
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<div tabindex="0" role="button" class="btn btn-sm w-full justify-between">
|
<div tabindex="0" role="button" class="btn btn-sm w-full justify-between">
|
||||||
<span class="flex items-center gap-1.5">
|
{selected_label(@bands, @selected_band)}
|
||||||
<.icon name="hero-signal" class="size-4" />
|
|
||||||
{selected_label(@bands, @selected_band)}
|
|
||||||
</span>
|
|
||||||
<.icon name="hero-chevron-down" class="size-3" />
|
<.icon name="hero-chevron-down" class="size-3" />
|
||||||
</div>
|
</div>
|
||||||
<ul
|
<ul
|
||||||
|
|
@ -179,6 +223,21 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
<span class="text-sm">Grid squares</span>
|
<span class="text-sm">Grid squares</span>
|
||||||
</label>
|
</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 --%>
|
<%!-- Latest data --%>
|
||||||
<div :if={@valid_time} class="text-xs opacity-70 px-1">
|
<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)})
|
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
|
||||||
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
|
describe "toggle_grid" do
|
||||||
test "toggles grid visibility", %{conn: conn} do
|
test "toggles grid visibility", %{conn: conn} do
|
||||||
{:ok, lv, html} = live(conn, ~p"/map")
|
{:ok, lv, html} = live(conn, ~p"/map")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue