diff --git a/lib/microwaveprop/rover/coverage.ex b/lib/microwaveprop/rover/coverage.ex new file mode 100644 index 00000000..6f0c33f6 --- /dev/null +++ b/lib/microwaveprop/rover/coverage.ex @@ -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 diff --git a/lib/microwaveprop_web/live/rover_live.ex b/lib/microwaveprop_web/live/rover_live.ex index 0573eb43..0f5b782c 100644 --- a/lib/microwaveprop_web/live/rover_live.ex +++ b/lib/microwaveprop_web/live/rover_live.ex @@ -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 diff --git a/test/microwaveprop/rover/coverage_test.exs b/test/microwaveprop/rover/coverage_test.exs new file mode 100644 index 00000000..ee7b43a3 --- /dev/null +++ b/test/microwaveprop/rover/coverage_test.exs @@ -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