defmodule MicrowavepropWeb.PathLive do
@moduledoc "`/path` terrain-profile + propagation analysis for a pair of grids/coords."
use MicrowavepropWeb, :live_view
import MicrowavepropWeb.Components.SkewTChart
import MicrowavepropWeb.LiveHelpers, only: [parse_float: 2, parse_int: 2]
alias Microwaveprop.Buildings.Index, as: BuildingsIndex
alias Microwaveprop.Buildings.Loader, as: BuildingsLoader
alias Microwaveprop.Canopy
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.PathCompute
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo
alias Microwaveprop.RoverPlanning.Path, as: RoverPath
require Logger
@band_options BandConfig.band_options()
@url_params ~w(source destination band src_height_ft dst_height_ft tx_power_dbm src_gain_dbi dst_gain_dbi)
@defaults %{
"source" => "",
"destination" => "",
"band" => "10000",
"src_height_ft" => "30",
"dst_height_ft" => "30",
"tx_power_dbm" => "20",
"src_gain_dbi" => "30",
"dst_gain_dbi" => "30"
}
@impl true
def mount(_params, _session, socket) do
_ =
if connected?(socket) do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
end
{:ok,
assign(socket,
page_title: "Path Calculator",
band_options: @band_options,
result: nil,
error: nil,
computing: false,
compute_step: 0,
compute_total: PathCompute.total_stages(),
compute_label: nil,
source: "",
destination: "",
band: "10000",
src_height_ft: "30",
dst_height_ft: "30",
tx_power_dbm: "20",
src_gain_dbi: "30",
dst_gain_dbi: "30",
source_is_gps: false
)}
end
@impl true
def handle_params(%{"rover_path_id" => rover_path_id} = _params, _uri, socket)
when is_binary(rover_path_id) and rover_path_id != "" do
case load_cached_path(rover_path_id) do
{:ok, path, result} ->
params = params_from_cached(path, result)
socket =
assign(socket,
source: params["source"],
destination: params["destination"],
band: params["band"],
src_height_ft: params["src_height_ft"],
dst_height_ft: params["dst_height_ft"],
tx_power_dbm: params["tx_power_dbm"],
src_gain_dbi: params["src_gain_dbi"],
dst_gain_dbi: params["dst_gain_dbi"],
source_is_gps: false,
result: result,
computing: false,
error: nil
)
{:noreply, socket}
{:error, :not_found} ->
{:noreply,
socket
|> put_flash(:error, "Cached path profile not found.")
|> push_navigate(to: ~p"/path")}
{:error, :stale_term} ->
# Cached blob can't be deserialized (e.g. old enrichment from
# before this code shipped). Fall through to live recompute
# using the path's stored source/dest/band.
case load_cached_path_for_redirect(rover_path_id) do
{:ok, params} -> {:noreply, push_patch(socket, to: ~p"/path?#{params}", replace: true)}
{:error, _} -> {:noreply, push_navigate(socket, to: ~p"/path")}
end
end
end
def handle_params(params, _uri, socket) do
p = Map.merge(@defaults, Map.take(params, @url_params))
is_gps = p["source"] == "gps"
# Clear stale results when URL params change — otherwise a cached
# result from a previous calculation persists when the user navigates
# to a different source/destination/band combination.
new_source = if(is_gps, do: socket.assigns.source, else: p["source"])
params_changed? = params_differ?(socket, p, new_source)
socket =
if params_changed? do
assign(socket, result: nil, error: nil)
else
socket
end
socket =
assign(socket,
source: new_source,
destination: p["destination"],
band: p["band"],
src_height_ft: p["src_height_ft"],
dst_height_ft: p["dst_height_ft"],
tx_power_dbm: p["tx_power_dbm"],
src_gain_dbi: p["src_gain_dbi"],
dst_gain_dbi: p["dst_gain_dbi"],
source_is_gps: is_gps
)
# If source=gps and we already have coords from a previous fix, just recalculate.
# Only request GPS from the device on the initial page load (no coords yet).
if p["source"] == "gps" and connected?(socket) do
if socket.assigns.source != "" and socket.assigns.source != nil do
auto_calculate(socket, Map.put(p, "source", socket.assigns.source))
else
{:noreply, push_event(socket, "request_gps", %{})}
end
else
auto_calculate(socket, p)
end
end
defp params_differ?(socket, p, new_source) do
socket.assigns.result != nil and
(new_source != socket.assigns.source or
p["destination"] != socket.assigns.destination or
p["band"] != socket.assigns.band or
p["src_height_ft"] != socket.assigns.src_height_ft or
p["dst_height_ft"] != socket.assigns.dst_height_ft or
p["tx_power_dbm"] != socket.assigns.tx_power_dbm or
p["src_gain_dbi"] != socket.assigns.src_gain_dbi or
p["dst_gain_dbi"] != socket.assigns.dst_gain_dbi)
end
# Loads a cached rover-planning Path and rebuilds the @result map
# PathLive's render expects. The blob lives under `result["term"]`
# (Base64-encoded `:erlang.term_to_binary/1` of PathCompute's full
# output). `:safe` mode rejects unknown atoms — every atom we
# roundtrip is one this codebase already references at compile time,
# so it's known.
defp load_cached_path(rover_path_id) do
with {:ok, uuid} <- Ecto.UUID.cast(rover_path_id),
%RoverPath{result: %{"term" => term}} <- Repo.get(RoverPath, uuid),
{:ok, decoded} <- decode_term(term) do
{:ok, nil, decoded}
else
%RoverPath{} -> {:error, :stale_term}
nil -> {:error, :not_found}
:error -> {:error, :not_found}
{:error, _} = err -> err
end
end
# Fall-back: when the cached term is missing/stale, redirect to
# /path with the source/dest/band fields filled in from the Path's
# rover_location + station so the user gets a live recompute.
defp load_cached_path_for_redirect(rover_path_id) do
with {:ok, uuid} <- Ecto.UUID.cast(rover_path_id),
%RoverPath{} = path <-
RoverPath |> Repo.get(uuid) |> Repo.preload([:rover_location, :station, :mission]) do
{:ok,
%{
"source" => Maidenhead.from_latlon(path.rover_location.lat, path.rover_location.lon, 10),
"destination" =>
path.station.callsign || path.station.grid ||
"#{path.station.lat},#{path.station.lon}",
"band" => Integer.to_string(path.band_mhz || path.mission.band_mhz),
"src_height_ft" => Float.to_string((path.mission.rover_height_ft || 8.0) * 1.0),
"dst_height_ft" => Float.to_string((path.mission.station_height_ft || 30.0) * 1.0)
}}
else
_ -> {:error, :not_found}
end
end
defp decode_term(term) when is_binary(term) do
with {:ok, binary} <- Base.decode64(term),
decoded when is_map(decoded) <- safe_binary_to_term(binary) do
{:ok, decoded}
else
_ -> {:error, :stale_term}
end
rescue
_ -> {:error, :stale_term}
end
defp safe_binary_to_term(binary) do
:erlang.binary_to_term(binary, [:safe])
rescue
_ -> nil
end
defp params_from_cached(_path, result) do
sp = result.station_params || %{}
%{
"source" => label_for(result.source),
"destination" => label_for(result.destination),
"band" => Integer.to_string(result.band_mhz),
"src_height_ft" => Float.to_string((Map.get(sp, :src_height_ft) || 30.0) * 1.0),
"dst_height_ft" => Float.to_string((Map.get(sp, :dst_height_ft) || 30.0) * 1.0),
"tx_power_dbm" => Float.to_string((Map.get(sp, :tx_power_dbm) || 20.0) * 1.0),
"src_gain_dbi" => Float.to_string((Map.get(sp, :src_gain_dbi) || 30.0) * 1.0),
"dst_gain_dbi" => Float.to_string((Map.get(sp, :dst_gain_dbi) || 30.0) * 1.0)
}
end
defp label_for(%{label: label}) when is_binary(label), do: label
defp label_for(_), do: ""
defp auto_calculate(socket, p) do
if p["source"] != "" and p["source"] != "gps" and p["destination"] != "" and
not socket.assigns.computing and is_nil(socket.assigns.result) do
src_ht = parse_float(p["src_height_ft"], 30.0)
dst_ht = parse_float(p["dst_height_ft"], 30.0)
tx_dbm = parse_float(p["tx_power_dbm"], 20.0)
station_params = %{
src_height_ft: src_ht,
dst_height_ft: dst_ht,
src_height_m: src_ht * 0.3048,
dst_height_m: dst_ht * 0.3048,
tx_power_dbm: tx_dbm,
tx_power_mw: :math.pow(10, tx_dbm / 10),
src_gain_dbi: parse_float(p["src_gain_dbi"], 30.0),
dst_gain_dbi: parse_float(p["dst_gain_dbi"], 30.0)
}
{:noreply,
start_compute_async(
socket,
p["source"],
p["destination"],
parse_int(p["band"], 10_000),
station_params
)}
else
{:noreply, socket}
end
end
# Kicks off the path compute in a Task so the LV can keep rendering
# progress messages while it runs. The Task captures the LV pid and
# sends `{:compute_progress, step, total, label}` messages from
# `PathCompute`'s `:on_progress` callback as each stage starts;
# `handle_async(:compute_path, ...)` picks up the final result.
defp start_compute_async(socket, source, dest, band_mhz, station_params) do
lv = self()
on_progress = fn step, total, label ->
send(lv, {:compute_progress, step, total, label})
end
socket
|> assign(
computing: true,
error: nil,
result: nil,
compute_step: 0,
compute_total: PathCompute.total_stages(),
compute_label: "Starting…"
)
|> start_async(:compute_path, fn ->
PathCompute.compute(source, dest, band_mhz, station_params, on_progress: on_progress)
end)
end
@impl true
def handle_event("update_form", params, socket) do
{:noreply,
assign(socket,
source: params["source"] || "",
destination: params["destination"] || "",
band: params["band"] || "10000",
src_height_ft: params["src_height_ft"] || "30",
dst_height_ft: params["dst_height_ft"] || "30",
tx_power_dbm: params["tx_power_dbm"] || "20",
src_gain_dbi: params["src_gain_dbi"] || "30",
dst_gain_dbi: params["dst_gain_dbi"] || "30"
)}
end
def handle_event("calculate", params, socket) do
src_height_ft = parse_float(params["src_height_ft"], 30.0)
dst_height_ft = parse_float(params["dst_height_ft"], 30.0)
tx_power_dbm = parse_float(params["tx_power_dbm"], 20.0)
tx_power_mw = :math.pow(10, tx_power_dbm / 10)
station_params = %{
src_height_ft: src_height_ft,
dst_height_ft: dst_height_ft,
src_height_m: src_height_ft * 0.3048,
dst_height_m: dst_height_ft * 0.3048,
tx_power_dbm: tx_power_dbm,
tx_power_mw: tx_power_mw,
src_gain_dbi: parse_float(params["src_gain_dbi"], 30.0),
dst_gain_dbi: parse_float(params["dst_gain_dbi"], 30.0)
}
socket =
socket
|> assign(
source: params["source"],
destination: params["destination"],
band: params["band"],
src_height_ft: params["src_height_ft"],
dst_height_ft: params["dst_height_ft"],
tx_power_dbm: params["tx_power_dbm"],
src_gain_dbi: params["src_gain_dbi"],
dst_gain_dbi: params["dst_gain_dbi"]
)
|> start_compute_async(
params["source"],
params["destination"],
parse_int(params["band"], 10_000),
station_params
)
url_params =
params
|> Map.take(@url_params)
|> then(fn p -> if socket.assigns.source_is_gps, do: Map.put(p, "source", "gps"), else: p end)
|> Enum.reject(fn {_k, v} -> v == "" end)
|> Map.new()
{:noreply, push_patch(socket, to: ~p"/path?#{url_params}", replace: true)}
end
# Hover on a forecast-chart dot — fetch and push back the per-factor
# breakdown for that hour at the path's midpoint, mirroring the
# weighted-criteria panel the main /map popup builds when the user
# clicks a grid cell. Cached client-side so the server only does the
# ProfilesFile read once per hour per session.
def handle_event("path_forecast_detail", %{"iso" => iso}, socket) do
with %{result: %{band_mhz: band_mhz, source: src, destination: dst, band_config: band_config}} <-
socket.assigns,
{:ok, valid_time, _} <- DateTime.from_iso8601(iso) do
midlat = (src.lat + dst.lat) / 2
midlon = (src.lon + dst.lon) / 2
payload =
case Propagation.point_detail(band_mhz, midlat, midlon, valid_time) do
%{score: score, factors: factors} when factors != %{} ->
%{
iso: iso,
score: score,
factors: factors,
band_label: band_config.label,
humidity_effect: BandConfig.humidity_effect_label(band_config)
}
_ ->
%{iso: iso, unavailable: true}
end
{:noreply, push_event(socket, "path_forecast_detail", payload)}
else
_ -> {:noreply, socket}
end
end
def handle_event("gps_location", %{"lat" => lat, "lon" => lon}, socket) do
coords = "#{Float.round(lat / 1, 6)},#{Float.round(lon / 1, 6)}"
# Show coords in the input but keep source=gps in the URL
socket = assign(socket, source: coords, source_is_gps: true, result: nil)
url_params =
%{
"source" => "gps",
"destination" => socket.assigns.destination,
"band" => socket.assigns.band,
"src_height_ft" => socket.assigns.src_height_ft,
"dst_height_ft" => socket.assigns.dst_height_ft,
"tx_power_dbm" => socket.assigns.tx_power_dbm,
"src_gain_dbi" => socket.assigns.src_gain_dbi,
"dst_gain_dbi" => socket.assigns.dst_gain_dbi
}
|> Enum.reject(fn {_k, v} -> v == "" end)
|> Map.new()
{:noreply, push_patch(socket, to: ~p"/path?#{url_params}", replace: true)}
end
@impl true
def handle_info({:compute_progress, step, total, label}, socket) do
# Drop stale progress messages from any task we no longer care about.
if socket.assigns.computing do
{:noreply, assign(socket, compute_step: step, compute_total: total, compute_label: label)}
else
{:noreply, socket}
end
end
def handle_info({:propagation_updated, _valid_times}, socket) do
case socket.assigns.result do
%{band_mhz: band_mhz, source: src, destination: dst} = result ->
midlat = (src.lat + dst.lat) / 2
midlon = (src.lon + dst.lon) / 2
forecast = Propagation.point_forecast(band_mhz, midlat, midlon)
{:noreply, assign(socket, result: %{result | forecast: forecast})}
_ ->
{:noreply, socket}
end
end
@impl true
def handle_async(:compute_path, {:ok, {:ok, result}}, socket) do
{:noreply,
assign(socket,
result: result,
computing: false,
compute_label: nil,
compute_step: 0,
error: nil
)}
end
def handle_async(:compute_path, {:ok, {:error, reason}}, socket) do
{:noreply,
assign(socket,
error: reason,
computing: false,
compute_label: nil,
compute_step: 0
)}
end
def handle_async(:compute_path, {:exit, reason}, socket) do
Logger.error("PathLive compute task crashed: #{inspect(reason)}")
{:noreply,
assign(socket,
error: "Internal error computing path. Please try again.",
computing: false,
compute_label: nil,
compute_step: 0
)}
end
# ── Score tier helpers ──
defp tier_label(score) when score >= 80, do: "EXCELLENT"
defp tier_label(score) when score >= 65, do: "GOOD"
defp tier_label(score) when score >= 50, do: "MARGINAL"
defp tier_label(score) when score >= 33, do: "POOR"
defp tier_label(_), do: "NEGLIGIBLE"
defp tier_color(score) when score >= 80, do: "#059669"
defp tier_color(score) when score >= 65, do: "#0d9488"
defp tier_color(score) when score >= 50, do: "#ca8a04"
defp tier_color(score) when score >= 33, do: "#ea580c"
defp tier_color(_), do: "#dc2626"
defp terrain_verdict_class("CLEAR"), do: "badge badge-success"
defp terrain_verdict_class("FRESNEL_MINOR"), do: "badge badge-warning"
defp terrain_verdict_class("FRESNEL_PARTIAL"), do: "badge badge-warning"
defp terrain_verdict_class("BLOCKED"), do: "badge badge-error"
defp terrain_verdict_class(_), do: "badge"
defp format_number(n) when is_float(n), do: :erlang.float_to_binary(n, decimals: 1)
defp format_number(n), do: to_string(n)
defp format_small(n) when is_float(n), do: :erlang.float_to_binary(n, decimals: 4)
defp format_small(n), do: to_string(n)
defp format_km_mi(km), do: Microwaveprop.Format.distance_km(km)
defp format_utc_hour(%DateTime{} = dt), do: Calendar.strftime(dt, "%H:%M")
defp format_utc_hour(_), do: "?"
defp format_mw(mw) when mw >= 1000, do: "#{format_number(mw / 1000)} W"
defp format_mw(mw) when mw >= 1, do: "#{format_number(mw)} mW"
defp format_mw(mw), do: "#{format_number(mw * 1000)} uW"
# ── Render ──
@impl true
def render(assigns) do
~H"""
| Point | Lat, Lon | Temp | Dewpt | Press | HPBL | PWAT | Ns | dN/dh |
|---|---|---|---|---|---|---|---|---|
| {pt.label} | {format_number(pt.profile.lat)}, {format_number(pt.profile.lon)} | {format_number(pt.profile.surface_temp_c)}°C | {format_number(pt.profile.surface_dewpoint_c)}°C | {format_number(pt.profile.surface_pressure_mb)} mb | {format_number(pt.profile.hpbl_m)} m | {format_number(pt.profile.pwat_mm)} mm | {format_number(pt.profile.surface_refractivity)} | {format_number(pt.profile.min_refractivity_gradient)} |