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