defmodule MicrowavepropWeb.RoverLive do @moduledoc """ Rover planner: enter stationary stations you want to work, select a band, and the map colors areas by coverage quality — how many stations you can reach from each point given current terrain and propagation conditions. """ 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 @band_options [ {"10 GHz", "10000"}, {"24 GHz", "24000"}, {"47 GHz", "47000"}, {"76 GHz", "75000"}, {"122 GHz", "122000"}, {"241 GHz", "241000"} ] @impl true def mount(_params, _session, socket) do {:ok, assign(socket, page_title: "Rover Planner", band_options: @band_options, band: 10_000, stations: [], station_input: "", coverage: [], selected_grid: nil, resolving: false, computing: false, url_loaded: false )} end @impl true def handle_params(params, _uri, socket) do if socket.assigns.url_loaded do {:noreply, socket} else band = if params["band"], do: String.to_integer(params["band"]), else: socket.assigns.band socket = assign(socket, band: band, url_loaded: true) # Load stations from URL: ?stations=W5ISP,K5TR,EM00cd case params["stations"] do nil -> {:noreply, socket} stations_str -> calls = stations_str |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) send(self(), {:resolve_station_list, calls}) {:noreply, assign(socket, resolving: true)} end end end # ── Events ── @impl true def handle_event("add_station", %{"callsign" => input}, socket) do input = String.trim(input) if input == "" do {:noreply, socket} else send(self(), {:resolve_station, input}) {:noreply, assign(socket, resolving: true, station_input: "")} end end def handle_event("remove_station", %{"index" => idx_str}, socket) do idx = String.to_integer(idx_str) stations = List.delete_at(socket.assigns.stations, idx) socket = assign(socket, stations: stations, coverage: [], selected_grid: nil) if length(stations) >= 2, do: send(self(), :compute_coverage) socket = push_event(socket, "stations_updated", %{stations: station_data(stations)}) {:noreply, push_url(socket)} end def handle_event("select_band", %{"band" => band_str}, socket) do socket = assign(socket, band: String.to_integer(band_str), coverage: [], selected_grid: nil) if length(socket.assigns.stations) >= 2, do: send(self(), :compute_coverage) {:noreply, push_url(socket)} end def handle_event("select_grid", %{"grid" => grid}, socket) do detail = Enum.find(socket.assigns.coverage, &(&1.grid == grid)) {:noreply, assign(socket, selected_grid: detail)} end # ── Async ── def handle_info({:resolve_station_list, calls}, socket) do stations = calls |> Task.async_stream(&resolve_station/1, max_concurrency: 4, timeout: 10_000) |> Enum.flat_map(fn {:ok, {:ok, s}} -> [s] _ -> [] end) socket = socket |> assign(stations: stations, resolving: false) |> push_event("stations_updated", %{stations: station_data(stations)}) if length(stations) >= 2, do: send(self(), :compute_coverage) {:noreply, socket} end @impl true def handle_info({:resolve_station, input}, socket) do case resolve_station(input) do {:ok, station} -> stations = socket.assigns.stations ++ [station] socket = assign(socket, stations: stations, resolving: false) socket = push_event(socket, "stations_updated", %{stations: station_data(stations)}) if length(stations) >= 2, do: send(self(), :compute_coverage) {:noreply, push_url(socket)} {:error, _reason} -> {:noreply, socket |> assign(resolving: false) |> put_flash(:error, "Could not find: #{input}")} end end def handle_info(:compute_coverage, socket) do stations = socket.assigns.stations band_mhz = socket.assigns.band band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000) max_range = band_config.extended_range_km || 500 socket = assign(socket, computing: true) # 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) # Push to JS for rendering grid_data = Enum.map(coverage, fn c -> %{ grid: c.grid, lat: c.lat, lon: c.lon, coverage_score: c.coverage_score, stations_in_range: c.stations_in_range, workable_count: c.workable_count, total_stations: length(stations), prop_score: c.prop_score, best_hour: c.best_hour } end) socket = socket |> assign(coverage: coverage, computing: false, selected_grid: nil) |> push_event("coverage_updated", %{grids: grid_data}) {:noreply, socket} 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 input = String.trim(input) if Regex.match?(~r/^[A-Ra-r]{2}[0-9]{2}/i, input) and String.length(input) >= 4 do case Maidenhead.to_latlon(input) do {:ok, {lat, lon}} -> {:ok, %{label: String.upcase(input), lat: lat, lon: lon, type: :grid}} :error -> {:error, "invalid grid"} end else case CallsignClient.locate(input) do {:ok, info} -> {:ok, %{label: String.upcase(info.callsign), lat: info.lat, lon: info.lon, type: :callsign}} {:error, reason} -> {:error, reason} end end end defp push_url(socket) do labels = Enum.map_join(socket.assigns.stations, ",", & &1.label) params = then(%{"band" => to_string(socket.assigns.band)}, fn p -> if labels == "", do: p, else: Map.put(p, "stations", labels) end) push_patch(socket, to: ~p"/rover?#{params}", replace: true) end defp station_data(stations) do Enum.map(stations, fn s -> %{label: s.label, lat: s.lat, lon: s.lon} end) end defp verdict_badge("CLEAR"), do: "badge badge-success badge-sm" defp verdict_badge("FRESNEL_MINOR"), do: "badge badge-warning badge-sm" defp verdict_badge("FRESNEL_PARTIAL"), do: "badge badge-warning badge-sm" defp verdict_badge("BLOCKED"), do: "badge badge-error badge-sm" defp verdict_badge(_), do: "badge badge-sm" defp tier_color(score) when score >= 80, do: "#00ffa3" defp tier_color(score) when score >= 65, do: "#7dffd4" defp tier_color(score) when score >= 50, do: "#ffe566" defp tier_color(score) when score >= 33, do: "#ff9044" defp tier_color(_), do: "#ff4f4f" # ── Render ── @impl true def render(assigns) do ~H"""