prop/lib/microwaveprop/rover/coverage.ex
Graham McIntire 6aec3eeea4 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
2026-04-07 16:08:03 -05:00

203 lines
6.6 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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