prop/lib/microwaveprop_web/live/rover_live.ex
Graham McIntire 9ee2b9dd1b Rover planner: auto-compute coverage areas with terrain + propagation
Rewrites rover planner to automatically color the map after stations
are entered. No manual stop selection needed — the system evaluates
every Maidenhead grid in range and scores them by:

- SRTM terrain analysis to each station (diffraction loss, LOS verdict)
- HRRR propagation forecast (ducting, refractivity, atmospheric conditions)
- Distance to stations vs band range limits
- Propagation boost: good conditions (score 80+) make blocked paths
  viable through ducting, matching the app's core beyond-LOS prediction

Coverage score formula: boosted path quality 30% + propagation 30%
+ distance 20% + station count 20%. Colored grid squares on the map
show best (green) to worst (red) operating positions.

Sidebar shows top 10 grids ranked with: workable station count,
propagation score, best operating hour, 18-hour forecast sparkline.
Click a grid for per-station detail: distance, terrain verdict,
diffraction loss.

URL stores stations + band for sharing: /rover?stations=W5ISP,K5TR&band=10000
2026-04-07 15:43:28 -05:00

530 lines
18 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"""
<div class="relative w-screen h-screen overflow-hidden">
<div
id="rover-map"
phx-hook="RoverMap"
phx-update="ignore"
data-band={@band}
class="absolute inset-0 z-0"
>
</div>
<%!-- Sidebar --%>
<div class="absolute top-2 left-2 z-[1000] w-80 max-h-[calc(100vh-1rem)] overflow-y-auto bg-base-100/95 shadow-lg rounded-box border border-base-300 p-3 flex flex-col gap-3">
<div class="font-bold text-sm">Rover Planner</div>
<select phx-change="select_band" name="band" class="select select-bordered select-sm w-full">
<%= for {label, value} <- @band_options do %>
<option value={value} selected={value == to_string(@band)}>{label}</option>
<% end %>
</select>
<form phx-submit="add_station" class="flex gap-1">
<input
type="text"
name="callsign"
value={@station_input}
placeholder="Add station (call or grid)"
class="input input-bordered input-sm flex-1"
/>
<button type="submit" class="btn btn-sm btn-primary" disabled={@resolving}>
<%= if @resolving do %>
<span class="loading loading-spinner loading-xs"></span>
<% else %>
Add
<% end %>
</button>
</form>
<%= if @stations != [] do %>
<div class="text-xs opacity-60">Stationary Stations ({length(@stations)})</div>
<div class="flex flex-wrap gap-1">
<%= for {station, idx} <- Enum.with_index(@stations) do %>
<div class="badge badge-outline gap-1">
{station.label}
<button
type="button"
phx-click="remove_station"
phx-value-index={idx}
class="cursor-pointer opacity-50 hover:opacity-100"
>
x
</button>
</div>
<% end %>
</div>
<% end %>
<%= if @computing do %>
<div class="text-xs text-center py-3">
<span class="loading loading-spinner loading-sm"></span>
<div class="mt-1">Computing coverage areas...</div>
</div>
<% end %>
<%= if @stations != [] and !@computing and @coverage == [] do %>
<div class="text-xs opacity-50 text-center py-2">
Add at least 2 stations to see coverage
</div>
<% end %>
<%!-- Top grids ranked --%>
<%= if @coverage != [] do %>
<div class="divider my-0"></div>
<div class="text-xs opacity-60">Best Operating Grids</div>
<%= for {grid, rank} <- @coverage |> Enum.take(10) |> Enum.with_index(1) do %>
<div
class={[
"bg-base-200 rounded-lg p-2 text-xs cursor-pointer hover:bg-base-300 transition-colors",
@selected_grid && @selected_grid.grid == grid.grid && "ring-2 ring-primary"
]}
phx-click="select_grid"
phx-value-grid={grid.grid}
>
<div class="flex justify-between items-center">
<div class="font-bold">
<span
class="inline-block w-5 h-5 rounded-full text-center leading-5 text-[10px] font-bold mr-1"
style={"background-color: #{tier_color(grid.coverage_score)}; color: #000"}
>
{rank}
</span>
{grid.grid}
</div>
<div class="font-mono font-bold" style={"color: #{tier_color(grid.coverage_score)}"}>
{grid.coverage_score}
</div>
</div>
<div class="mt-1 flex justify-between opacity-70">
<span>{grid.workable_count}/{grid.total_stations} workable</span>
<span>Prop: {grid.prop_score}</span>
</div>
<%= if grid.best_hour do %>
<div class="opacity-50">Best at {grid.best_hour} UTC</div>
<% end %>
<%= if grid.forecast != [] do %>
<div class="flex items-end gap-px h-3 mt-1">
<%= for point <- grid.forecast do %>
<div
class="flex-1 rounded-t-sm"
style={"height: #{max(point.score, 5)}%; background-color: #{tier_color(point.score)}; min-width: 2px"}
title={"#{Calendar.strftime(point.valid_time, "%H:%M")} UTC: #{point.score}"}
>
</div>
<% end %>
</div>
<% end %>
</div>
<% end %>
<% end %>
<%!-- Selected grid detail --%>
<%= if @selected_grid do %>
<div class="divider my-0"></div>
<div class="text-xs opacity-60">
{@selected_grid.grid} — Station Distances
</div>
<%= for s <- Enum.sort_by(@selected_grid.station_details, & &1.dist_km) do %>
<div class="flex justify-between items-center text-xs bg-base-200 rounded px-2 py-1">
<div>
<span class="font-bold">{s.label}</span>
<span class="opacity-50 ml-1">{Float.round(s.dist_km, 0)} km</span>
</div>
<div class="flex gap-1 items-center">
<%= if s.verdict do %>
<span class={verdict_badge(s.verdict)}>{s.verdict}</span>
<% end %>
<%= if s.diffraction_db && s.diffraction_db > 0 do %>
<span class="opacity-50 text-[10px]">{s.diffraction_db} dB</span>
<% end %>
<%= if !s.in_range do %>
<span class="badge badge-error badge-sm">out of range</span>
<% end %>
</div>
</div>
<% end %>
<% end %>
</div>
</div>
"""
end
end