- 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
203 lines
6.6 KiB
Elixir
203 lines
6.6 KiB
Elixir
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
|