prop/lib/microwaveprop_web/live/map_live.ex
Graham McIntire 02cb4fd67b
Integrate ML model into grid worker, QSO search, and UI improvements
ML Integration:
- Load trained model at app startup, cache compiled predict fn in persistent_term
- Grid worker uses batched ML prediction (10K chunks) when model loaded,
  falls back to algorithm scorer when not
- ML score replaces composite, algorithm factor scores preserved for detail view
- Fix process explosion: single EXLA call per chunk instead of per-grid-point

QSO Features:
- Callsign search (ILIKE on station1/station2) with trigram indexes
- Reciprocal QSO grouping (same pair, same band, same hour)
- Wider layout (max-w-7xl) for data table pages
- QSO Training Data link on map page

Infrastructure:
- Re-enable hourly propagation grid worker in dev
- Track ML model weights in git for Docker builds
- Add btree indexes on qsos (timestamp, band, distance_km)
- Remove nav icons from layout header
2026-04-01 10:14:22 -05:00

366 lines
11 KiB
Elixir

defmodule MicrowavepropWeb.MapLive do
@moduledoc false
use MicrowavepropWeb, :live_view
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Terrain.Viewshed
require Logger
@default_band 10_000
@initial_bounds %{
"south" => 29.5,
"north" => 36.3,
"west" => -101.5,
"east" => -92.5
}
@impl true
def mount(_params, _session, socket) do
bands = BandConfig.all_bands()
valid_times = Propagation.available_valid_times(@default_band)
selected_time = closest_to_now(valid_times)
initial_scores = Propagation.scores_at(@default_band, selected_time, @initial_bounds)
if connected?(socket) do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
end
{:ok,
assign(socket,
page_title: "Propagation Map",
bands: bands,
selected_band: @default_band,
initial_scores_json: Jason.encode!(initial_scores),
valid_times: valid_times,
selected_time: selected_time,
bounds: @initial_bounds,
grid_visible: false,
antenna_height_ft: 33
)}
end
@impl true
def handle_event("select_band", %{"value" => band}, socket) do
band = if is_binary(band), do: String.to_integer(band), else: band
valid_times = Propagation.available_valid_times(band)
selected_time = closest_to_now(valid_times)
scores = Propagation.scores_at(band, selected_time, socket.assigns.bounds)
socket =
socket
|> assign(selected_band: band, valid_times: valid_times, selected_time: selected_time)
|> push_event("update_scores", %{scores: scores})
|> push_event("update_band_info", %{band_info: band_info(band)})
|> push_timeline()
{:noreply, socket}
end
def handle_event("select_time", %{"time" => time_str}, socket) do
case DateTime.from_iso8601(time_str) do
{:ok, time, _} ->
scores = Propagation.scores_at(socket.assigns.selected_band, time, socket.assigns.bounds)
socket =
socket
|> assign(:selected_time, time)
|> push_event("update_scores", %{scores: scores})
{:noreply, socket}
_ ->
{:noreply, socket}
end
end
def handle_event("set_antenna_height", %{"height_ft" => value}, socket) do
height =
case Integer.parse(value) do
{h, _} when h >= 0 and h <= 200 -> h
_ -> 8
end
{:noreply, assign(socket, :antenna_height_ft, height)}
end
def handle_event("toggle_grid", _params, socket) do
visible = !socket.assigns.grid_visible
socket =
socket
|> assign(:grid_visible, visible)
|> push_event("toggle_grid", %{visible: visible})
{:noreply, socket}
end
def handle_event("point_detail", %{"lat" => lat, "lon" => lon}, socket) do
band = socket.assigns.selected_band
detail = Propagation.point_detail(band, lat, lon)
forecast = Propagation.point_forecast(band, lat, lon)
forecast_data =
Enum.map(forecast, fn f ->
%{time: DateTime.to_iso8601(f.valid_time), score: f.score}
end)
payload = if detail, do: Map.put(detail, :forecast, forecast_data), else: %{}
{:noreply, push_event(socket, "point_detail", payload)}
end
def handle_event("map_bounds", bounds, socket) do
scores = Propagation.scores_at(socket.assigns.selected_band, socket.assigns.selected_time, bounds)
socket =
socket
|> assign(:bounds, bounds)
|> push_event("update_scores", %{scores: scores})
{:noreply, socket}
end
def handle_event("compute_viewshed", %{"lat" => lat, "lon" => lon}, socket) do
band_mhz = socket.assigns.selected_band
band_config = BandConfig.get(band_mhz)
freq_ghz = band_mhz / 1_000
ant_height_m = socket.assigns.antenna_height_ft * 0.3048
score =
case Propagation.point_detail(band_mhz, lat, lon) do
%{score: s} -> s
_ -> 50
end
max_range_km = score_range_km(score, band_config)
socket =
start_async(socket, :viewshed, fn ->
Viewshed.compute(lat, lon,
freq_ghz: freq_ghz,
ant_height_m: ant_height_m,
max_range_km: max_range_km,
score: score
)
end)
{:noreply, socket}
end
@impl true
def handle_info({:propagation_updated, _valid_times}, socket) do
band = socket.assigns.selected_band
valid_times = Propagation.available_valid_times(band)
# Stay on the same selected time if still available, else pick earliest
selected_time =
if socket.assigns.selected_time in valid_times do
socket.assigns.selected_time
else
closest_to_now(valid_times)
end
scores = Propagation.scores_at(band, selected_time, socket.assigns.bounds)
socket =
socket
|> assign(valid_times: valid_times, selected_time: selected_time)
|> push_event("update_scores", %{scores: scores})
|> push_timeline()
{:noreply, socket}
end
@impl true
def handle_async(:viewshed, {:ok, result}, socket) do
points = Enum.map(result.boundary, fn p -> %{lat: p.lat, lon: p.lon} end)
Logger.info("Viewshed complete: #{length(points)} boundary points")
socket =
push_event(socket, "viewshed_result", %{
origin: result.origin,
points: points
})
{:noreply, socket}
end
def handle_async(:viewshed, {:exit, reason}, socket) do
Logger.warning("Viewshed computation failed: #{inspect(reason)}")
{:noreply, socket}
end
defp push_timeline(socket) do
times =
Enum.map(socket.assigns.valid_times, fn t ->
%{time: DateTime.to_iso8601(t), label: format_time_label(t)}
end)
selected =
if socket.assigns.selected_time,
do: DateTime.to_iso8601(socket.assigns.selected_time)
push_event(socket, "update_timeline", %{times: times, selected: selected})
end
defp format_time_label(dt) do
# Show as "HH:MM" in UTC
Calendar.strftime(dt, "%H:%M")
end
defp closest_to_now([]), do: nil
defp closest_to_now(times) do
now = DateTime.utc_now()
Enum.min_by(times, fn t -> abs(DateTime.diff(t, now)) end)
end
defp score_range_km(score, config) do
cond do
score >= 80 -> config.exceptional_range_km
score >= 65 -> config.extended_range_km
score >= 50 -> config.typical_range_km
score >= 33 -> round(config.typical_range_km * 0.6)
true -> round(config.typical_range_km * 0.3)
end
end
defp band_info(band_mhz) do
config = BandConfig.get(band_mhz)
%{
band_label: config.label,
typical_range_km: config.typical_range_km,
extended_range_km: config.extended_range_km,
exceptional_range_km: config.exceptional_range_km,
humidity_effect: to_string(config.humidity_effect),
freq_mhz: config.freq_mhz
}
end
defp selected_label(bands, selected_band) do
case Enum.find(bands, &(&1.freq_mhz == selected_band)) do
nil -> "Select Band"
band -> band.label
end
end
@impl true
def render(assigns) do
~H"""
<div class="relative w-screen h-screen">
<%!-- Full-page map --%>
<div
id="propagation-map"
phx-hook="PropagationMap"
phx-update="ignore"
data-scores={@initial_scores_json}
data-band-info={Jason.encode!(band_info(@selected_band))}
data-valid-times={
Jason.encode!(
Enum.map(@valid_times, fn t ->
%{time: DateTime.to_iso8601(t), label: format_time_label(t)}
end)
)
}
data-selected-time={if @selected_time, do: DateTime.to_iso8601(@selected_time), else: ""}
class="absolute inset-0 z-0"
>
</div>
<%!-- Top-left control panel --%>
<div id="map-controls" class="absolute top-3 left-14 z-[1000] flex flex-col gap-2">
<div class="bg-base-100/90 shadow rounded-box border border-base-300 p-3 flex flex-col gap-2">
<div class="font-bold text-sm leading-tight px-1">
North Texas Microwave Society
<div class="font-normal text-xs opacity-70">Propagation Map</div>
</div>
<%!-- Band selector --%>
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-sm w-full justify-between">
{selected_label(@bands, @selected_band)}
<.icon name="hero-chevron-down" class="size-3" />
</div>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box shadow-lg w-44 mt-1 p-1"
>
<li :for={band <- @bands}>
<button
onclick="document.activeElement.blur()"
phx-click={
JS.dispatch("show-loading", to: "#propagation-map")
|> JS.push("select_band", value: %{value: band.freq_mhz})
}
class={[if(@selected_band == band.freq_mhz, do: "active")]}
>
{band.label}
</button>
</li>
</ul>
</div>
<%!-- Grid toggle --%>
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@grid_visible}
phx-click="toggle_grid"
/>
<span class="text-sm">Grid squares</span>
</label>
<%!-- Antenna height --%>
<form phx-change="set_antenna_height" class="flex items-center gap-2 px-1">
<label class="text-sm whitespace-nowrap">Antenna height</label>
<input
type="number"
name="height_ft"
min="0"
max="200"
step="1"
value={@antenna_height_ft}
class="input input-xs w-16 text-right"
/>
<span class="text-xs opacity-70">ft</span>
</form>
<%!-- Links --%>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<.link navigate="/algo" class="btn btn-xs btn-ghost justify-start gap-1.5">
<.icon name="hero-calculator" class="size-3.5" /> Scoring Algorithm
</.link>
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start gap-1.5">
<.icon name="hero-arrow-up-tray" class="size-3.5" /> Submit a QSO
</.link>
<.link navigate="/qsos" class="btn btn-xs btn-ghost justify-start gap-1.5">
<.icon name="hero-signal" class="size-3.5" /> QSO Training Data
</.link>
</div>
</div>
<%!-- Point detail panel (populated by JS on click, ignored by LiveView patches) --%>
<div
id="detail-panel"
phx-update="ignore"
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 overflow-hidden"
style="display:none;"
>
</div>
</div>
<%!-- Bottom timeline bar --%>
<div
id="forecast-timeline"
phx-update="ignore"
class="absolute bottom-4 left-1/2 -translate-x-1/2 z-[1000]"
>
</div>
</div>
<Layouts.flash_group flash={@flash} />
"""
end
end