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
530 lines
18 KiB
Elixir
530 lines
18 KiB
Elixir
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
|