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:
Graham McIntire 2026-04-07 16:08:01 -05:00
parent 89898893fc
commit 6aec3eeea4
3 changed files with 310 additions and 174 deletions

View 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

View file

@ -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

View 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