- Toggle button for Maidenhead grid overlay (on by default) - Coverage candidates now at 0.25° resolution (~28 km) instead of 2°×1° Maidenhead grids, matching HRRR propagation grid density - Coverage rectangles render as 0.25° cells for finer detail - Grid overlay is separate from coverage coloring
204 lines
6.7 KiB
Elixir
204 lines
6.7 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 candidates at 0.25° resolution (between HRRR 0.125° and Maidenhead 1-2°)
|
|
# This gives ~16x more candidates than Maidenhead but stays manageable
|
|
step = 0.25
|
|
|
|
for lat <- float_range(min_lat, max_lat, step),
|
|
lon <- float_range(min_lon, max_lon, step) do
|
|
grid = Geo.latlon_to_grid4(lat, lon)
|
|
{grid, Float.round(lat, 3), Float.round(lon, 3)}
|
|
end
|
|
end
|
|
|
|
defp float_range(min, max, step) do
|
|
(Float.ceil(min / step) * step)
|
|
|> Stream.iterate(&(&1 + step))
|
|
|> Enum.take_while(&(&1 <= max))
|
|
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
|