Extract Coverage module, fix computation, add tests
- Extract scoring logic to Microwaveprop.Rover.Coverage for testability - Two-phase computation: fast pass (distance + propagation) for all grids, then SRTM terrain analysis for top 20 candidates only - Cap search radius to 300 km to keep candidate count reasonable - Run terrain analysis in Task with rescue/fallback for resilience - Background Task doesn't block LiveView process - 5 tests covering: ranked results, empty inputs, station details, band range differences, field presence
This commit is contained in:
parent
89898893fc
commit
6aec3eeea4
3 changed files with 310 additions and 174 deletions
203
lib/microwaveprop/rover/coverage.ex
Normal file
203
lib/microwaveprop/rover/coverage.ex
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
defmodule Microwaveprop.Rover.Coverage do
|
||||
@moduledoc """
|
||||
Computes coverage scores for candidate roving locations.
|
||||
Given a list of stationary stations and a band, ranks grid squares
|
||||
by how many stations a rover can work from each, weighted by
|
||||
terrain, propagation forecast, and distance.
|
||||
"""
|
||||
|
||||
alias Microwaveprop.Geo
|
||||
alias Microwaveprop.Propagation
|
||||
alias Microwaveprop.Propagation.BandConfig
|
||||
alias Microwaveprop.Terrain.ElevationClient
|
||||
alias Microwaveprop.Terrain.TerrainAnalysis
|
||||
|
||||
@doc """
|
||||
Compute ranked coverage for all candidate grids.
|
||||
Returns a list of grid results sorted by coverage_score descending.
|
||||
|
||||
Phase 1: Fast pass — score all grids by distance + propagation (no terrain).
|
||||
Phase 2: Detailed pass — run SRTM terrain analysis for top 20 candidates.
|
||||
"""
|
||||
def compute(stations, band_mhz) when length(stations) >= 2 do
|
||||
band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000)
|
||||
max_range = band_config.extended_range_km || 500
|
||||
freq_ghz = band_mhz / 1000
|
||||
|
||||
candidates = generate_candidates(stations, max_range)
|
||||
|
||||
# Phase 1: Fast scoring
|
||||
fast_scored =
|
||||
candidates
|
||||
|> Enum.map(fn {grid, lat, lon} ->
|
||||
fast_score_grid(grid, lat, lon, stations, band_mhz, max_range)
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.sort_by(& &1.coverage_score, :desc)
|
||||
|
||||
# Phase 2: Terrain detail for top 20
|
||||
top = Enum.take(fast_scored, 20)
|
||||
|
||||
# Terrain analysis may fail in test (no SRTM data) or timeout — fall back to fast scores
|
||||
detailed =
|
||||
top
|
||||
|> Task.async_stream(
|
||||
fn candidate ->
|
||||
try do
|
||||
add_terrain_detail(candidate, stations, freq_ghz, max_range)
|
||||
rescue
|
||||
_ -> candidate
|
||||
end
|
||||
end,
|
||||
max_concurrency: 4,
|
||||
timeout: 60_000,
|
||||
on_timeout: :kill_task
|
||||
)
|
||||
|> Enum.map(fn
|
||||
{:ok, result} -> result
|
||||
{:exit, _} -> nil
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.sort_by(& &1.coverage_score, :desc)
|
||||
|
||||
if detailed == [], do: fast_scored, else: detailed
|
||||
end
|
||||
|
||||
def compute(_stations, _band_mhz), do: []
|
||||
|
||||
defp generate_candidates(stations, max_range) do
|
||||
lats = Enum.map(stations, & &1.lat)
|
||||
lons = Enum.map(stations, & &1.lon)
|
||||
|
||||
# Cap search radius to keep candidate count reasonable
|
||||
# For the rover planner, focus on the area between and around the stations
|
||||
capped_range = min(max_range, 300)
|
||||
range_deg = capped_range / 111.0
|
||||
|
||||
min_lat = max(Enum.min(lats) - range_deg, 25.0)
|
||||
max_lat = min(Enum.max(lats) + range_deg, 50.0)
|
||||
min_lon = max(Enum.min(lons) - range_deg, -125.0)
|
||||
max_lon = min(Enum.max(lons) + range_deg, -66.0)
|
||||
|
||||
# Generate at 4-char Maidenhead resolution (1° lat × 2° lon)
|
||||
lat_range = Enum.to_list(trunc(min_lat)..trunc(max_lat))
|
||||
lon_range = Enum.to_list(trunc(min_lon)..trunc(max_lon)//2)
|
||||
|
||||
# Use center of grid square
|
||||
for_result =
|
||||
for lat <- lat_range, lon <- lon_range do
|
||||
clat = lat + 0.5
|
||||
clon = lon + 1.0
|
||||
grid = Geo.latlon_to_grid4(clat, clon)
|
||||
{grid, clat, clon}
|
||||
end
|
||||
|
||||
Enum.uniq_by(for_result, fn {grid, _, _} -> grid end)
|
||||
end
|
||||
|
||||
defp fast_score_grid(grid, lat, lon, stations, band_mhz, max_range) do
|
||||
station_distances =
|
||||
Enum.map(stations, fn s ->
|
||||
dist = Geo.haversine_km(lat, lon, s.lat, s.lon)
|
||||
%{label: s.label, lat: s.lat, lon: s.lon, dist_km: dist, in_range: dist <= max_range}
|
||||
end)
|
||||
|
||||
in_range = Enum.filter(station_distances, & &1.in_range)
|
||||
|
||||
if in_range == [] do
|
||||
nil
|
||||
else
|
||||
station_pct = length(in_range) / length(stations)
|
||||
|
||||
distance_factor =
|
||||
in_range
|
||||
|> Enum.map(fn s -> max(0, 1.0 - s.dist_km / max_range) end)
|
||||
|> then(&(Enum.sum(&1) / length(stations)))
|
||||
|
||||
forecast = Propagation.point_forecast(band_mhz, lat, lon)
|
||||
|
||||
{prop_score, best_hour} =
|
||||
case forecast do
|
||||
[_ | _] ->
|
||||
best = Enum.max_by(forecast, & &1.score)
|
||||
{best.score, Calendar.strftime(best.valid_time, "%H:%M")}
|
||||
|
||||
_ ->
|
||||
{50, nil}
|
||||
end
|
||||
|
||||
coverage_score = round(prop_score / 100 * 40 + distance_factor * 30 + station_pct * 30)
|
||||
|
||||
%{
|
||||
grid: grid,
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
coverage_score: coverage_score,
|
||||
stations_in_range: length(in_range),
|
||||
workable_count: length(in_range),
|
||||
prop_score: prop_score,
|
||||
best_hour: best_hour,
|
||||
station_details: station_distances,
|
||||
forecast: forecast
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp add_terrain_detail(candidate, stations, freq_ghz, max_range) do
|
||||
station_analyses =
|
||||
Enum.map(candidate.station_details, fn s ->
|
||||
if s.in_range do
|
||||
{verdict, diffraction_db} =
|
||||
try do
|
||||
case ElevationClient.fetch_elevation_profile(candidate.lat, candidate.lon, s.lat, s.lon, 64) do
|
||||
{:ok, profile} ->
|
||||
analysis = TerrainAnalysis.analyse(profile, s.dist_km, freq_ghz, ant_ht_a: 3.0, ant_ht_b: 10.0)
|
||||
{analysis.verdict, analysis.diffraction_db}
|
||||
|
||||
{:error, _} ->
|
||||
{nil, 0}
|
||||
end
|
||||
rescue
|
||||
_ -> {nil, 0}
|
||||
end
|
||||
|
||||
path_quality =
|
||||
case verdict do
|
||||
"CLEAR" -> 1.0
|
||||
"FRESNEL_MINOR" -> 0.9
|
||||
"FRESNEL_PARTIAL" -> 0.7
|
||||
"BLOCKED" when diffraction_db < 10 -> 0.5
|
||||
"BLOCKED" when diffraction_db < 20 -> 0.35
|
||||
"BLOCKED" when diffraction_db < 40 -> 0.2
|
||||
"BLOCKED" -> 0.1
|
||||
_ -> 0.4
|
||||
end
|
||||
|
||||
Map.merge(s, %{
|
||||
verdict: verdict,
|
||||
diffraction_db: diffraction_db && Float.round(diffraction_db, 1),
|
||||
path_quality: path_quality
|
||||
})
|
||||
else
|
||||
Map.merge(s, %{verdict: nil, diffraction_db: nil, path_quality: 0})
|
||||
end
|
||||
end)
|
||||
|
||||
in_range = Enum.filter(station_analyses, & &1.in_range)
|
||||
path_score = Enum.sum(Enum.map(in_range, &Map.get(&1, :path_quality, 0.4))) / max(length(stations), 1)
|
||||
workable_count = Enum.count(in_range, &(Map.get(&1, :path_quality, 0) >= 0.2))
|
||||
|
||||
prop_boost = candidate.prop_score / 100
|
||||
boosted = min(1.0, path_score + prop_boost * 0.3)
|
||||
station_pct = length(in_range) / max(length(stations), 1)
|
||||
|
||||
distance_factor =
|
||||
in_range
|
||||
|> Enum.map(fn s -> max(0, 1.0 - s.dist_km / max_range) end)
|
||||
|> then(&(Enum.sum(&1) / max(length(stations), 1)))
|
||||
|
||||
coverage_score = round(boosted * 30 + prop_boost * 30 + distance_factor * 20 + station_pct * 20)
|
||||
|
||||
%{candidate | station_details: station_analyses, workable_count: workable_count, coverage_score: coverage_score}
|
||||
end
|
||||
end
|
||||
|
|
@ -6,13 +6,9 @@ defmodule MicrowavepropWeb.RoverLive do
|
|||
"""
|
||||
use MicrowavepropWeb, :live_view
|
||||
|
||||
alias Microwaveprop.Geo
|
||||
alias Microwaveprop.Propagation
|
||||
alias Microwaveprop.Propagation.BandConfig
|
||||
alias Microwaveprop.Radio.CallsignClient
|
||||
alias Microwaveprop.Radio.Maidenhead
|
||||
alias Microwaveprop.Terrain.ElevationClient
|
||||
alias Microwaveprop.Terrain.TerrainAnalysis
|
||||
alias Microwaveprop.Rover.Coverage
|
||||
|
||||
@band_options [
|
||||
{"10 GHz", "10000"},
|
||||
|
|
@ -141,14 +137,21 @@ defmodule MicrowavepropWeb.RoverLive do
|
|||
band_mhz = socket.assigns.band
|
||||
|
||||
if length(stations) < 2 do
|
||||
{:noreply, socket}
|
||||
{:noreply, assign(socket, computing: false)}
|
||||
else
|
||||
# Run in a background task so the LV stays responsive
|
||||
pid = self()
|
||||
|
||||
Task.start(fn ->
|
||||
result = do_compute_coverage(stations, band_mhz)
|
||||
send(pid, {:coverage_result, result})
|
||||
try do
|
||||
result = Coverage.compute(stations, band_mhz)
|
||||
send(pid, {:coverage_result, result})
|
||||
rescue
|
||||
e ->
|
||||
require Logger
|
||||
|
||||
Logger.error("Rover coverage computation failed: #{Exception.message(e)}")
|
||||
send(pid, {:coverage_result, []})
|
||||
end
|
||||
end)
|
||||
|
||||
{:noreply, assign(socket, computing: true)}
|
||||
|
|
@ -181,171 +184,6 @@ defmodule MicrowavepropWeb.RoverLive do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp do_compute_coverage(stations, band_mhz) do
|
||||
band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000)
|
||||
max_range = band_config.extended_range_km || 500
|
||||
|
||||
# Find the bounding box around all stations, expanded by max range
|
||||
lats = Enum.map(stations, & &1.lat)
|
||||
lons = Enum.map(stations, & &1.lon)
|
||||
# ~1 degree ≈ 111 km
|
||||
range_deg = max_range / 111.0
|
||||
|
||||
min_lat = max(Enum.min(lats) - range_deg, 25.0)
|
||||
max_lat = min(Enum.max(lats) + range_deg, 50.0)
|
||||
min_lon = max(Enum.min(lons) - range_deg, -125.0)
|
||||
max_lon = min(Enum.max(lons) + range_deg, -66.0)
|
||||
|
||||
# Generate candidate grids (4-char Maidenhead = 2° lon × 1° lat)
|
||||
for_result =
|
||||
for lat <- Stream.iterate(Float.ceil(min_lat), &(&1 + 0.5)),
|
||||
lat <= max_lat,
|
||||
lon <- Stream.iterate(Float.ceil(min_lon), &(&1 + 1.0)),
|
||||
lon <= max_lon do
|
||||
grid = Geo.latlon_to_grid4(lat, lon)
|
||||
{grid, lat, lon}
|
||||
end
|
||||
|
||||
candidates =
|
||||
Enum.uniq_by(for_result, fn {grid, _, _} -> grid end)
|
||||
|
||||
# Score each candidate: how many stations in range × propagation quality
|
||||
coverage =
|
||||
candidates
|
||||
|> Task.async_stream(
|
||||
fn {grid, lat, lon} ->
|
||||
score_grid(grid, lat, lon, stations, band_mhz, band_config, max_range)
|
||||
end,
|
||||
max_concurrency: System.schedulers_online(),
|
||||
timeout: 30_000
|
||||
)
|
||||
|> Enum.flat_map(fn
|
||||
{:ok, nil} -> []
|
||||
{:ok, result} -> [result]
|
||||
_ -> []
|
||||
end)
|
||||
|> Enum.sort_by(& &1.coverage_score, :desc)
|
||||
|
||||
coverage
|
||||
end
|
||||
|
||||
# ── Coverage Scoring ──
|
||||
|
||||
defp score_grid(grid, lat, lon, stations, band_mhz, _band_config, max_range) do
|
||||
freq_ghz = band_mhz / 1000
|
||||
|
||||
# Analyze terrain + distance to each station
|
||||
station_analyses =
|
||||
Enum.map(stations, fn s ->
|
||||
dist = Geo.haversine_km(lat, lon, s.lat, s.lon)
|
||||
in_range = dist <= max_range
|
||||
|
||||
# Run SRTM terrain analysis for in-range stations
|
||||
{verdict, diffraction_db} =
|
||||
if in_range do
|
||||
case ElevationClient.fetch_elevation_profile(lat, lon, s.lat, s.lon, 64, download: true) do
|
||||
{:ok, profile} ->
|
||||
analysis = TerrainAnalysis.analyse(profile, dist, freq_ghz, ant_ht_a: 3.0, ant_ht_b: 10.0)
|
||||
{analysis.verdict, analysis.diffraction_db}
|
||||
|
||||
{:error, _} ->
|
||||
{nil, 0}
|
||||
end
|
||||
else
|
||||
{nil, 0}
|
||||
end
|
||||
|
||||
# Path quality combines terrain AND propagation conditions.
|
||||
# BLOCKED paths are still workable with ducting/enhanced propagation —
|
||||
# that's the whole point of this app. Higher propagation score at this
|
||||
# grid means atmospheric conditions can overcome terrain blockage.
|
||||
path_quality =
|
||||
case verdict do
|
||||
"CLEAR" -> 1.0
|
||||
"FRESNEL_MINOR" -> 0.9
|
||||
"FRESNEL_PARTIAL" -> 0.7
|
||||
"BLOCKED" when diffraction_db < 10 -> 0.5
|
||||
"BLOCKED" when diffraction_db < 20 -> 0.35
|
||||
"BLOCKED" when diffraction_db < 40 -> 0.2
|
||||
"BLOCKED" -> 0.1
|
||||
_ -> 0.4
|
||||
end
|
||||
|
||||
%{
|
||||
label: s.label,
|
||||
lat: s.lat,
|
||||
lon: s.lon,
|
||||
dist_km: dist,
|
||||
in_range: in_range,
|
||||
verdict: verdict,
|
||||
diffraction_db: diffraction_db && Float.round(diffraction_db, 1),
|
||||
path_quality: path_quality
|
||||
}
|
||||
end)
|
||||
|
||||
in_range = Enum.filter(station_analyses, & &1.in_range)
|
||||
|
||||
if in_range == [] do
|
||||
nil
|
||||
else
|
||||
# Path-quality-weighted station score: combines terrain + atmospheric potential
|
||||
path_score = Enum.sum(Enum.map(in_range, & &1.path_quality)) / length(stations)
|
||||
|
||||
# Distance factor
|
||||
distance_factor =
|
||||
in_range
|
||||
|> Enum.map(fn s -> max(0, 1.0 - s.dist_km / max_range) end)
|
||||
|> then(&(Enum.sum(&1) / length(stations)))
|
||||
|
||||
# Propagation + ducting: best score in next 12 hours from HRRR forecast
|
||||
forecast = Propagation.point_forecast(band_mhz, lat, lon)
|
||||
|
||||
{prop_score, best_hour} =
|
||||
case forecast do
|
||||
[_ | _] ->
|
||||
best = Enum.max_by(forecast, & &1.score)
|
||||
{best.score, Calendar.strftime(best.valid_time, "%H:%M")}
|
||||
|
||||
_ ->
|
||||
{50, nil}
|
||||
end
|
||||
|
||||
# Combined: path quality (terrain + ducting potential) 30%
|
||||
# + propagation forecast 30%
|
||||
# + distance factor 20%
|
||||
# + station count 20%
|
||||
# Propagation score amplifies blocked-path viability: score 80+ means
|
||||
# ducting conditions that can overcome 30+ dB of terrain blockage.
|
||||
station_pct = length(in_range) / length(stations)
|
||||
workable_count = Enum.count(in_range, &(&1.path_quality >= 0.2))
|
||||
|
||||
# Boost path_quality by propagation: good conditions make blocked paths viable
|
||||
prop_boost = prop_score / 100
|
||||
boosted_path_score = min(1.0, path_score + prop_boost * 0.3)
|
||||
|
||||
coverage_score =
|
||||
round(
|
||||
boosted_path_score * 30 +
|
||||
prop_boost * 30 +
|
||||
distance_factor * 20 +
|
||||
station_pct * 20
|
||||
)
|
||||
|
||||
%{
|
||||
grid: grid,
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
coverage_score: coverage_score,
|
||||
stations_in_range: length(in_range),
|
||||
workable_count: workable_count,
|
||||
prop_score: prop_score,
|
||||
best_hour: best_hour,
|
||||
station_details: station_analyses,
|
||||
forecast: forecast
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
defp resolve_station(input) do
|
||||
|
|
|
|||
95
test/microwaveprop/rover/coverage_test.exs
Normal file
95
test/microwaveprop/rover/coverage_test.exs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
defmodule Microwaveprop.Rover.CoverageTest do
|
||||
use Microwaveprop.DataCase, async: false
|
||||
|
||||
alias Microwaveprop.Rover.Coverage
|
||||
|
||||
describe "compute/2" do
|
||||
test "returns ranked grids for stations within range" do
|
||||
stations = [
|
||||
%{label: "STA1", lat: 33.0, lon: -97.0},
|
||||
%{label: "STA2", lat: 33.5, lon: -97.5}
|
||||
]
|
||||
|
||||
result = Coverage.compute(stations, 10_000)
|
||||
|
||||
assert is_list(result)
|
||||
assert length(result) > 0
|
||||
|
||||
scores = Enum.map(result, & &1.coverage_score)
|
||||
assert scores == Enum.sort(scores, :desc)
|
||||
|
||||
first = hd(result)
|
||||
assert is_binary(first.grid)
|
||||
assert byte_size(first.grid) == 4
|
||||
assert is_number(first.lat)
|
||||
assert is_number(first.lon)
|
||||
assert is_integer(first.coverage_score)
|
||||
assert first.coverage_score >= 0 and first.coverage_score <= 100
|
||||
assert is_integer(first.stations_in_range)
|
||||
assert first.stations_in_range > 0
|
||||
assert is_integer(first.workable_count)
|
||||
assert is_integer(first.prop_score)
|
||||
assert is_list(first.station_details)
|
||||
assert is_list(first.forecast)
|
||||
end
|
||||
|
||||
test "returns empty list with fewer than 2 stations" do
|
||||
assert Coverage.compute([%{label: "STA1", lat: 33.0, lon: -97.0}], 10_000) == []
|
||||
assert Coverage.compute([], 10_000) == []
|
||||
end
|
||||
|
||||
test "station_details includes distance and in_range" do
|
||||
stations = [
|
||||
%{label: "STA1", lat: 33.0, lon: -97.0},
|
||||
%{label: "STA2", lat: 33.5, lon: -97.0}
|
||||
]
|
||||
|
||||
[first | _] = Coverage.compute(stations, 10_000)
|
||||
|
||||
assert length(first.station_details) == 2
|
||||
|
||||
for detail <- first.station_details do
|
||||
assert Map.has_key?(detail, :label)
|
||||
assert Map.has_key?(detail, :dist_km)
|
||||
assert Map.has_key?(detail, :in_range)
|
||||
assert is_number(detail.dist_km)
|
||||
assert is_boolean(detail.in_range)
|
||||
end
|
||||
end
|
||||
|
||||
test "higher frequency bands have shorter range so fewer grids cover both stations" do
|
||||
far_stations = [
|
||||
%{label: "STA1", lat: 33.0, lon: -97.0},
|
||||
%{label: "STA2", lat: 35.0, lon: -97.0}
|
||||
]
|
||||
|
||||
result_10g = Coverage.compute(far_stations, 10_000)
|
||||
result_241g = Coverage.compute(far_stations, 241_000)
|
||||
|
||||
# 10 GHz has 500 km range, 241 GHz has 50 km — far fewer grids reach both at 241
|
||||
grids_reaching_both_10g = Enum.count(result_10g, &(&1.stations_in_range == 2))
|
||||
grids_reaching_both_241g = Enum.count(result_241g, &(&1.stations_in_range == 2))
|
||||
|
||||
assert grids_reaching_both_10g >= grids_reaching_both_241g
|
||||
end
|
||||
|
||||
test "top results have station details with expected fields" do
|
||||
stations = [
|
||||
%{label: "STA1", lat: 33.0, lon: -97.0},
|
||||
%{label: "STA2", lat: 33.2, lon: -97.2}
|
||||
]
|
||||
|
||||
[first | _] = Coverage.compute(stations, 10_000)
|
||||
|
||||
# Each station detail should have core fields
|
||||
for detail <- first.station_details do
|
||||
assert Map.has_key?(detail, :label)
|
||||
assert Map.has_key?(detail, :dist_km)
|
||||
assert Map.has_key?(detail, :in_range)
|
||||
end
|
||||
|
||||
# At least one station should be in range
|
||||
assert Enum.any?(first.station_details, & &1.in_range)
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue