prop/lib/microwaveprop_web/live/path_live.ex
Graham McIntire 0c3be97abb
Some checks failed
Build base image / Build and push base image (push) Successful in 3m10s
Build and Push / Build and Push Docker Image (push) Failing after 14s
Build prop-grid-rs / Test, build, push (push) Successful in 12m52s
fix: resolve 27 security, architecture, test, and performance audit findings
P0 (security-critical):
- Gate CSV/ADIF upload tabs behind authentication, add 30s cooldown to all upload handlers
- Cap CSV/ADIF imports at 2,000 rows server-side in both parsers
- Add submitter_verified boolean to contacts (client-cannot-set, anonymous=false)
- Create k8s/secret.example.yaml with placeholders, add LIVE_VIEW_SIGNING_SALT

P1 (high-priority):
- Add Mox.verify_on_exit!() to valkey_test.exs
- Replace DateTime.utc_now() truncation with static ~U literals in map_live_test.exs
- Replace Process.sleep with render_async in pskr_spots_live_test.exs (6 occurrences)
- Add MonitorLive.Show test coverage (4 tests: owner view, non-owner redirect, config success/error)
- Extract duct-detection and mechanism-classification logic from ContactLive.Show into Propagation.PathAnalysis
- Split ContactLive.Show render into 12 function components
- Update CLAUDE.md: remove stale ML model, mark HRDPS active, add backtest/pskr dirs
- Batch CSV import enrichment jobs via new enqueue_for_contacts/1

P2 (medium-priority):
- Set secure:true on session and remember-me cookies in production
- Change SMTP TLS from verify_none to verify_peer with public_key cacerts
- Make /metrics fail-closed in production when PROMETHEUS_AUTH_TOKEN unset
- Add RateLimiter (anon_limit:10, auth_limit:60) to /api/contacts/map
- Add content-security-policy-report-only header
- Add comment noting String.to_atom is compile-time safe in hrdps_client.ex
- Delegate duplicated haversine_km to canonical Microwaveprop.Geo.haversine_km/4
- Consolidate score-tier/color/verdict formatting into Microwaveprop.Format
- Update CLAUDE.md testing section to match actual raw-string-matching practice
- Batch HrrrPointEnqueuer Repo.insert_all calls to single round-trip
- Split weather.ex (1696→216 lines) and radio.ex (1285→54 lines) into purpose-based sub-facades

P3 (low-priority):
- Add LIVE_VIEW_SIGNING_SALT warning comment, extend filter_parameters
- Add host/community validation to snmp_client.ex
- Add raw/1 safety comment in algo_live.ex
- Add hex-audit and cargo-audit Makefile targets
- Add privacy_live smoke test
- Replace notify_listener busy-poll loop with Process.monitor/1 + assert_receive
- Add ContactCommonVolumeRadar changeset validation tests (5 tests)
2026-07-27 18:19:37 -05:00

1422 lines
47 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.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), do: Microwaveprop.Format.propagation_tier_label(score)
defp tier_color(score), do: Microwaveprop.Format.propagation_tier_color(score)
defp terrain_verdict_class(verdict), do: Microwaveprop.Format.terrain_verdict_class(verdict)
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"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
<.header>
Path Calculator
<:subtitle>Analyze microwave propagation between two points</:subtitle>
</.header>
<form
phx-submit="calculate"
phx-change="update_form"
id="path-form"
class="bg-base-200 rounded-box p-4 mb-6"
>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
<div class="fieldset">
<label class="label text-sm font-medium">Source</label>
<div class="flex gap-1">
<input
type="text"
name="source"
id="source-input"
value={@source}
placeholder="W5ISP, EM13sf, or lat,lon"
class="input input-bordered flex-1"
/>
<button
type="button"
id="locate-me"
phx-hook="LocateMe"
class="btn btn-sm btn-outline gap-1"
title="Use device GPS"
>
<.icon name="hero-map-pin" class="size-3.5" /> My Location
</button>
</div>
</div>
<div class="fieldset">
<label class="label text-sm font-medium">
Destination (<span
class="tooltip tooltip-top underline decoration-dotted cursor-help"
data-tip="Callsign lookup queries QRZ for the licensee's mailing address, then geocodes that address — so the position may be off from where the station actually operates."
>callsign</span>
or grid)
</label>
<input
type="text"
name="destination"
value={@destination}
placeholder="K5TR or EM00cd"
class="input input-bordered w-full"
required
/>
</div>
<div class="fieldset">
<label class="label text-sm font-medium">Band</label>
<select name="band" class="select select-bordered w-full">
<%= for {label, value} <- @band_options do %>
<option value={value} selected={value == @band}>{label}</option>
<% end %>
</select>
</div>
<div class="fieldset">
<label class="label text-sm font-medium">TX Power (dBm)</label>
<input
type="number"
name="tx_power_dbm"
value={@tx_power_dbm}
step="any"
class="input input-bordered w-full"
/>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 items-end mt-3">
<div class="fieldset">
<label class="label text-sm font-medium">TX Height (ft)</label>
<input
type="number"
name="src_height_ft"
value={@src_height_ft}
min="0"
step="any"
class="input input-bordered w-full"
/>
</div>
<div class="fieldset">
<label class="label text-sm font-medium">TX Gain (dBi)</label>
<input
type="number"
name="src_gain_dbi"
value={@src_gain_dbi}
step="any"
class="input input-bordered w-full"
/>
</div>
<div class="fieldset">
<label class="label text-sm font-medium">RX Height (ft)</label>
<input
type="number"
name="dst_height_ft"
value={@dst_height_ft}
min="0"
step="any"
class="input input-bordered w-full"
/>
</div>
<div class="fieldset">
<label class="label text-sm font-medium">RX Gain (dBi)</label>
<input
type="number"
name="dst_gain_dbi"
value={@dst_gain_dbi}
step="any"
class="input input-bordered w-full"
/>
</div>
</div>
<div class="mt-4 flex items-center gap-3">
<button type="submit" class="btn btn-primary" disabled={@computing}>
<%= if @computing do %>
<span class="loading loading-spinner loading-sm"></span>
<span>
{@compute_label || "Computing…"}
<%= if @compute_step > 0 do %>
<span class="opacity-70">({@compute_step}/{@compute_total})</span>
<% end %>
</span>
<% else %>
Calculate Path
<% end %>
</button>
<%= if @computing and @compute_total > 0 do %>
<progress
class="progress progress-primary w-40"
value={@compute_step}
max={@compute_total}
></progress>
<% end %>
</div>
</form>
<%= if @error do %>
<div class="alert alert-error mb-6">{@error}</div>
<% end %>
<%= if @result do %>
<.path_results result={@result} />
<% end %>
</Layouts.app>
"""
end
defp path_results(assigns) do
~H"""
<%!-- Map --%>
<div
id="path-map"
phx-hook="ContactMap"
phx-update="ignore"
data-pos1={Jason.encode!(%{lat: @result.source.lat, lon: @result.source.lon})}
data-pos2={Jason.encode!(%{lat: @result.destination.lat, lon: @result.destination.lon})}
data-station1={@result.source.label}
data-station2={@result.destination.label}
class="w-full h-64 md:h-80 rounded-box overflow-hidden mb-4"
>
</div>
<%!-- Terrain profile --%>
<%= if @result.terrain do %>
<div
id="path-elevation"
phx-hook="ElevationProfile"
phx-update="ignore"
data-profile={Jason.encode!(elevation_data(@result))}
class="w-full h-48 md:h-56 rounded-box overflow-hidden mb-6 bg-base-200 p-2"
>
<canvas></canvas>
</div>
<% end %>
<%!-- Link Summary --%>
<div class="bg-base-200 rounded-box p-4 mb-4">
<div class="text-xs opacity-60 mb-2">
Link Summary &middot; {@result.band_config.label}
</div>
<div class="grid grid-cols-2 md:grid-cols-5 gap-3 text-sm">
<div>
<span class="opacity-60">Distance</span>
<div class="font-mono text-lg">{format_km_mi(@result.dist_km)}</div>
</div>
<div>
<span class="opacity-60">Bearing</span>
<div class="font-mono text-lg">{format_number(@result.bearing)}&deg;</div>
</div>
<%= if @result.terrain do %>
<div>
<span class="opacity-60">Terrain</span>
<div>
<span class={["badge", terrain_verdict_class(@result.terrain.analysis.verdict)]}>
{@result.terrain.analysis.verdict}
</span>
</div>
</div>
<% end %>
<%= if @result.scoring do %>
<div>
<span class="opacity-60">Propagation</span>
<div class="font-mono text-lg" style={"color: #{tier_color(@result.scoring.score)}"}>
{@result.scoring.score}/100
</div>
<div class="text-xs" style={"color: #{tier_color(@result.scoring.score)}"}>
{tier_label(@result.scoring.score)}
</div>
</div>
<% end %>
<div>
<span class="opacity-60">HRRR Data</span>
<div class="font-mono">{@result.hrrr_count} / 9 points</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<%!-- Loss Budget --%>
<div class="bg-base-200 rounded-box p-4">
<div class="text-xs opacity-60 mb-2">
Loss Budget &middot; {@result.band_config.label}
</div>
<div class="space-y-1 text-sm">
<.budget_row label="FSPL (free-space)" value={@result.loss_budget.fspl} unit="dB" />
<.budget_row
label="Gaseous (O₂+H₂O)"
value={Float.round(@result.loss_budget.o2 + @result.loss_budget.h2o, 2)}
unit="dB"
/>
<%= if @result.loss_budget.diffraction > 0 do %>
<.budget_row
label="Diffraction loss"
value={@result.loss_budget.diffraction}
unit="dB"
highlight
/>
<% end %>
<%= if @result.loss_budget.rain > 0 do %>
<.budget_row label="Rain attenuation" value={@result.loss_budget.rain} unit="dB" />
<% end %>
<div class="divider my-1"></div>
<.budget_row
label="TOTAL PATH LOSS"
value={@result.loss_budget.total}
unit="dB"
bold
/>
</div>
<div class="mt-3 text-xs opacity-50 space-y-0.5">
<div>
Gas rate: {format_small(
(@result.loss_budget.o2 + @result.loss_budget.h2o) / max(@result.dist_km, 0.001)
)} dB/km
</div>
<div>
H&#8322;O: {format_small(@result.band_config.h2o_coeff)} dB/km/g &middot;
O&#8322;: {format_small(@result.band_config.o2_db_km)} dB/km
</div>
<%= if @result.conditions do %>
<div>Abs humidity avg: {format_number(@result.conditions.abs_humidity)} g/m&sup3;</div>
<% end %>
</div>
</div>
<%!-- Power Budget --%>
<div class="bg-base-200 rounded-box p-4">
<div class="text-xs opacity-60 mb-2">Power Budget</div>
<div class="space-y-1 text-sm">
<.budget_row
label="TX Power"
value={@result.power_budget.tx_power_dbm}
unit="dBm"
note={"(#{format_mw(@result.station_params.tx_power_mw)})"}
/>
<.budget_row
label="TX Ant Gain"
value={@result.station_params.src_gain_dbi}
unit="dBi"
positive
/>
<.budget_row label="Feed Loss (×2)" value={-2.0} unit="dB" />
<div class="divider my-1"></div>
<.budget_row label="EIRP" value={@result.power_budget.eirp_dbm - 2.0} unit="dBm" bold />
<.budget_row
label="Path Loss (w/FF)"
value={-@result.loss_budget.total}
unit="dB"
highlight
/>
<.budget_row
label="RX Ant Gain"
value={@result.station_params.dst_gain_dbi}
unit="dBi"
positive
/>
<div class="divider my-1"></div>
<.budget_row
label="RX Power"
value={@result.power_budget.rx_power_dbm - 2.0}
unit="dBm"
bold
/>
<.budget_row label="Sensitivity (CW)" value={-141.0} unit="dBm" />
<div class="divider my-1"></div>
<% margin = @result.power_budget.margin_cw - 2.0 %>
<.budget_row
label="MARGIN"
value={Float.round(margin, 1)}
unit="dB"
bold
color={if margin > 0, do: "text-success", else: "text-error"}
/>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<%!-- Path Weather --%>
<%= if @result.conditions do %>
<div class="bg-base-200 rounded-box p-4">
<div class="text-xs opacity-60 mb-3">Path Weather Avg</div>
<div class="grid grid-cols-2 gap-x-6 gap-y-3">
<div>
<span class="text-xs opacity-60">Temperature</span>
<div class="font-mono text-xl">{format_number(@result.conditions.temp_f)}&deg;F</div>
<div class="font-mono text-xs opacity-50">
{format_number(@result.conditions.temp_c)}&deg;C
</div>
</div>
<div>
<span class="text-xs opacity-60">Dewpoint</span>
<div class="font-mono text-xl">
{format_number(@result.conditions.dewpoint_f)}&deg;F
</div>
<div class="font-mono text-xs opacity-50">
{format_number(@result.conditions.dewpoint_c)}&deg;C
</div>
</div>
<div>
<span class="text-xs opacity-60">T/Td Gap</span>
<div class="font-mono text-xl">
{format_number(@result.conditions.temp_f - @result.conditions.dewpoint_f)}&deg;F
</div>
</div>
<div>
<span class="text-xs opacity-60">Abs Humidity</span>
<div class="font-mono text-xl">
{format_number(@result.conditions.abs_humidity)} g/m&sup3;
</div>
</div>
<div>
<span class="text-xs opacity-60">Pressure</span>
<div class="font-mono text-xl">
{format_number(@result.conditions.pressure_mb || 0)} mb
</div>
</div>
<div>
<span class="text-xs opacity-60">PWAT</span>
<div class="font-mono text-xl">
{format_number(@result.conditions.pwat_mm || 0)} mm
</div>
</div>
<div>
<span class="text-xs opacity-60">BL Depth</span>
<div class="font-mono text-xl">
{format_number(@result.conditions.bl_depth_m || 0)} m
</div>
</div>
<div>
<span class="text-xs opacity-60">Refrac Gradient</span>
<div class="font-mono text-xl">
{format_number(@result.conditions.min_refractivity_gradient || 0)} N/km
</div>
</div>
</div>
</div>
<% end %>
<%!-- Propagation Factors --%>
<%= if @result.scoring do %>
<div class="bg-base-200 rounded-box p-4">
<div class="text-xs opacity-60 mb-3">
Propagation Factors &middot; {@result.band_config.label}
</div>
<.factor_bars scoring={@result.scoring} />
</div>
<% end %>
</div>
<%!-- Atmospheric Profile (HRRR) --%>
<%= if @result.hrrr_points != [] do %>
<.atmospheric_profile hrrr_points={@result.hrrr_points} sounding={@result.sounding} />
<% end %>
<%!-- Ionosphere (GIRO sporadic-E readout) --%>
<%= if @result.ionosphere do %>
<.ionosphere_readout readout={@result.ionosphere} band_mhz={@result.band_mhz} />
<% end %>
<%!-- 48-Hour Forecast --%>
<%= if @result.forecast != [] do %>
<.forecast_chart forecast={@result.forecast} band_config={@result.band_config} />
<% end %>
<%!-- Share link --%>
<div class="mb-6">
<button
type="button"
phx-hook="CopyLink"
id="copy-link"
class="btn btn-sm btn-outline gap-1"
>
<.icon name="hero-link" class="size-3.5" /> Copy Link
</button>
</div>
"""
end
attr :hrrr_points, :list, required: true
attr :sounding, :any, default: nil
defp atmospheric_profile(assigns) do
mid = Enum.find(assigns.hrrr_points, &(&1.label == "Midpoint")) || hd(assigns.hrrr_points)
valid_time = mid.profile.valid_time
run_time = Map.get(mid.profile, :run_time)
duct_count = Enum.count(assigns.hrrr_points, & &1.profile.ducting_detected)
total_points = length(assigns.hrrr_points)
assigns =
assigns
|> assign(:mid, mid)
|> assign(:valid_time, valid_time)
|> assign(:run_time, run_time)
|> assign(:duct_count, duct_count)
|> assign(:total_points, total_points)
~H"""
<div class="bg-base-200 rounded-box p-4 mb-6">
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 mb-3">
<h3 class="text-base font-semibold">Atmospheric Profile</h3>
<span class="badge badge-outline badge-sm whitespace-nowrap">HRRR (3 km)</span>
<span class="text-xs opacity-60 whitespace-nowrap">
Valid {Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
</span>
<%= if @run_time do %>
<span class="text-xs opacity-60 whitespace-nowrap">
Run {Calendar.strftime(@run_time, "%Y-%m-%d %H:%M UTC")}
</span>
<% end %>
<span class="text-xs opacity-60 whitespace-nowrap">
{@total_points} path samples
</span>
<%= if @duct_count > 0 do %>
<span class="badge badge-warning badge-sm whitespace-nowrap">
Duct at {@duct_count} / {@total_points} HRRR points
</span>
<% end %>
<%= if @sounding && @sounding.ducting_detected do %>
<span class="badge badge-warning badge-sm whitespace-nowrap">
RAOB {@sounding.station_code} confirms duct
</span>
<% end %>
</div>
<%= if @sounding do %>
<div class="text-xs opacity-70 mb-3">
Nearest sounding: <span class="font-mono">{@sounding.station_code}</span>
<%= if @sounding.station_name do %>
({@sounding.station_name})
<% end %>
<%= if @sounding.distance_km do %>
· <span class="font-mono">{format_km_mi(@sounding.distance_km)}</span> from midpoint
<% end %>
· obs
<span class="font-mono">
{Calendar.strftime(@sounding.observed_at, "%Y-%m-%d %H:%M UTC")}
</span>
<%= if @sounding.min_refractivity_gradient do %>
· dN/dh
<span class="font-mono">
{format_number(@sounding.min_refractivity_gradient)} N/km
</span>
<% end %>
<%= if @sounding.precipitable_water_mm do %>
· PWAT <span class="font-mono">{format_number(@sounding.precipitable_water_mm)} mm</span>
<% end %>
</div>
<% end %>
<div class="overflow-x-auto">
<table class="table table-xs table-zebra">
<thead>
<tr>
<th>Point</th>
<th>Lat, Lon</th>
<th>Temp</th>
<th>Dewpt</th>
<th>Press</th>
<th>HPBL</th>
<th>PWAT</th>
<th>N<sub>s</sub></th>
<th>dN/dh</th>
</tr>
</thead>
<tbody>
<%= for pt <- @hrrr_points do %>
<tr>
<td class="font-semibold">{pt.label}</td>
<td class="font-mono opacity-70">
{format_number(pt.profile.lat)}, {format_number(pt.profile.lon)}
</td>
<td>{format_number(pt.profile.surface_temp_c)}&deg;C</td>
<td>{format_number(pt.profile.surface_dewpoint_c)}&deg;C</td>
<td>{format_number(pt.profile.surface_pressure_mb)} mb</td>
<td>{format_number(pt.profile.hpbl_m)} m</td>
<td>{format_number(pt.profile.pwat_mm)} mm</td>
<td>{format_number(pt.profile.surface_refractivity)}</td>
<td>{format_number(pt.profile.min_refractivity_gradient)}</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<%= if @mid.profile.profile && @mid.profile.profile != [] do %>
<div class="text-xs opacity-60 mt-4">
Midpoint vertical profile
</div>
<.skew_t_chart profile={@mid.profile.profile} />
<% end %>
</div>
"""
end
attr :readout, :map, required: true
attr :band_mhz, :integer, required: true
defp ionosphere_readout(assigns) do
~H"""
<div class="bg-base-200 rounded-box p-4 mb-6">
<div class="flex items-center justify-between mb-2">
<div class="text-xs opacity-60">Ionosphere &middot; Sporadic-E Potential</div>
<div class="text-[10px] opacity-50">
{@readout.station_code} · {Calendar.strftime(@readout.valid_time, "%Y-%m-%d %H:%M UTC")}
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
<div>
<div class="text-[10px] uppercase tracking-wider opacity-60">foEs</div>
<div class="font-bold text-lg">
{format_foes(@readout.fo_es_mhz)}
<span class="text-xs font-normal opacity-60">MHz</span>
</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wider opacity-60">foF2</div>
<div class="font-bold text-lg">
{format_foes(@readout.fo_f2_mhz)}
<span class="text-xs font-normal opacity-60">MHz</span>
</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wider opacity-60">Es MUF (path)</div>
<div class="font-bold text-lg">
{format_foes(@readout.es_muf_mhz)}
<span class="text-xs font-normal opacity-60">MHz</span>
</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wider opacity-60">
Es Score ({@band_mhz} MHz)
</div>
<div class="font-bold text-lg" style={"color: #{tier_color(@readout.es_score)}"}>
{@readout.es_score}
</div>
</div>
</div>
<div class="text-[11px] opacity-70 mt-3 leading-snug">
<%= cond do %>
<% not @readout.es_in_range? -> %>
Path length is outside the single-hop Es window (5002500 km) — tropo only for this distance.
<% @readout.es_score == 0 -> %>
No sporadic-E propagation predicted: the layer is not ionised enough at {@readout.station_code} to reflect {@band_mhz} MHz at this path length.
<% @readout.es_score >= 80 -> %>
Strong sporadic-E opening predicted — the Es MUF exceeds {@band_mhz} MHz for this path length.
<% @readout.es_score >= 50 -> %>
Marginal sporadic-E opening: MUF is near the band threshold, worth monitoring.
<% true -> %>
Weak sporadic-E fringe — the layer is ionised but not quite enough to reliably reflect {@band_mhz} MHz.
<% end %>
</div>
</div>
"""
end
defp format_foes(nil), do: ""
defp format_foes(v) when is_float(v), do: :erlang.float_to_binary(v, decimals: 1)
defp format_foes(v), do: to_string(v)
attr :forecast, :list, required: true
attr :band_config, :map, required: true
defp forecast_chart(assigns) do
points = build_forecast_points(assigns.forecast)
assigns =
assigns
|> assign(:points, points)
|> assign(:polyline, Enum.map_join(points, " ", fn p -> "#{p.x},#{p.y}" end))
|> assign(:now_pt, Enum.find(points, & &1.now?) || hd(points))
|> assign(:best_pt, Enum.max_by(points, & &1.score))
|> assign(:worst_pt, Enum.min_by(points, & &1.score))
|> assign(:trend, forecast_trend(points))
|> assign(:x_labels, forecast_x_labels(points))
~H"""
<div
id="path-forecast-chart"
phx-hook="PathForecast"
class="bg-base-200 rounded-box p-4 mb-6"
>
<div class="flex items-center justify-between mb-2">
<div class="text-xs opacity-60">
{length(@forecast)}-Hour Propagation Forecast &middot; {@band_config.label}
</div>
<div class="text-xs font-bold">
{Phoenix.HTML.raw(@trend)}
</div>
</div>
<svg viewBox="0 0 1200 240" class="w-full aspect-[5/1]">
<%= for s <- [0, 25, 50, 75, 100] do %>
<% y = 20 + (100 - s) / 100 * 185 %>
<line
x1="55"
y1={y}
x2="1180"
y2={y}
stroke="currentColor"
class="opacity-10"
stroke-width="1"
/>
<text
x="48"
y={y + 5}
font-size="14"
text-anchor="end"
fill="currentColor"
class="opacity-40"
>
{s}
</text>
<% end %>
<polyline
points={@polyline}
fill="none"
stroke={tier_color(@best_pt.score)}
stroke-width="3"
stroke-linejoin="round"
stroke-linecap="round"
/>
<%= for pt <- @points do %>
<circle
cx={pt.x}
cy={pt.y}
r="6"
fill={tier_color(pt.score)}
data-iso={DateTime.to_iso8601(pt.valid_time)}
style="cursor:pointer;"
/>
<% end %>
<circle
cx={@now_pt.x}
cy={@now_pt.y}
r="8"
fill="white"
stroke={tier_color(@now_pt.score)}
stroke-width="3"
data-iso={DateTime.to_iso8601(@now_pt.valid_time)}
style="cursor:pointer;"
/>
<circle
cx={@best_pt.x}
cy={@best_pt.y}
r="8"
fill={tier_color(@best_pt.score)}
data-iso={DateTime.to_iso8601(@best_pt.valid_time)}
style="cursor:pointer;"
stroke="white"
stroke-width="2"
/>
<text
x={@best_pt.x}
y={@best_pt.y - 12}
font-size="16"
text-anchor="middle"
font-weight="700"
fill={tier_color(@best_pt.score)}
>
{@best_pt.score}
</text>
<%= for {x, label} <- @x_labels do %>
<text
x={x}
y="230"
font-size="14"
text-anchor="middle"
fill="currentColor"
class="opacity-40"
>
{label}
</text>
<% end %>
</svg>
<div class="flex justify-between text-xs mt-2">
<span>
Best:
<span class="font-bold" style={"color: #{tier_color(@best_pt.score)}"}>
{@best_pt.score}
</span>
at {@best_pt.label} UTC
</span>
<span>
Worst:
<span class="font-bold" style={"color: #{tier_color(@worst_pt.score)}"}>
{@worst_pt.score}
</span>
at {@worst_pt.label} UTC
</span>
</div>
</div>
"""
end
defp build_forecast_points(forecast) do
n = length(forecast)
now = DateTime.utc_now()
# Map each forecast point to SVG coords (viewBox 0 0 1200 240)
# plot area: x in [55, 1180], y in [20, 205]
indexed = Enum.with_index(forecast)
now_idx =
indexed
|> Enum.min_by(fn {p, _i} -> abs(DateTime.diff(p.valid_time, now, :second)) end)
|> elem(1)
Enum.map(indexed, fn {p, i} ->
x =
if n <= 1 do
617
else
55 + i / (n - 1) * 1125
end
y = 20 + (100 - p.score) / 100 * 185
%{
x: Float.round(x * 1.0, 2),
y: Float.round(y * 1.0, 2),
score: p.score,
valid_time: p.valid_time,
label: format_utc_hour(p.valid_time),
now?: i == now_idx
}
end)
end
defp forecast_trend(points) do
now_idx = Enum.find_index(points, & &1.now?) || 0
future = Enum.drop(points, now_idx)
case future do
[first | _] ->
last = List.last(future)
diff = last.score - first.score
cond do
diff > 5 -> ~s(<span class="text-success">▲ Improving</span>)
diff < -5 -> ~s(<span class="text-error">▼ Declining</span>)
true -> ~s(<span class="text-warning">→ Steady</span>)
end
_ ->
~s(<span class="opacity-50">→ Steady</span>)
end
end
defp forecast_x_labels(points) do
n = length(points)
indices =
cond do
n <= 1 -> [0]
n <= 7 -> Enum.to_list(0..(n - 1))
true -> label_indices(n, 7)
end
Enum.map(indices, fn i ->
p = Enum.at(points, i)
{p.x, p.label}
end)
end
defp label_indices(n, target) do
step = (n - 1) / (target - 1)
0..(target - 1)
|> Enum.map(fn k -> round(k * step) end)
|> Enum.uniq()
end
attr :label, :string, required: true
attr :value, :any, required: true
attr :unit, :string, required: true
attr :note, :string, default: nil
attr :bold, :boolean, default: false
attr :highlight, :boolean, default: false
attr :positive, :boolean, default: false
attr :color, :string, default: nil
defp budget_row(assigns) do
~H"""
<div class={["flex justify-between", @bold && "font-bold", @color]}>
<span class={[@highlight && "text-warning"]}>{@label}</span>
<span class="font-mono">
<span class={[@positive && "text-success", @highlight && "text-warning"]}>
{format_value(@value)}
</span>
<span class="opacity-60 text-xs">{@unit}</span>
<%= if @note do %>
<span class="opacity-50 text-xs ml-1">{@note}</span>
<% end %>
</span>
</div>
"""
end
defp format_value(v) when is_float(v), do: :erlang.float_to_binary(v, decimals: 1)
defp format_value(v), do: to_string(v)
defp factor_bars(assigns) do
weights = BandConfig.weights()
rows =
assigns.scoring.factors
|> Enum.map(fn {key, score} ->
%{name: factor_name(key), score: score, weight: Map.get(weights, key, 0)}
end)
|> Enum.sort_by(& &1.score, :desc)
assigns = assign(assigns, :rows, rows)
~H"""
<div class="space-y-2">
<%= for row <- @rows do %>
<div>
<div class="flex justify-between text-xs mb-0.5">
<span class="opacity-70">{row.name}</span>
<span class="font-mono font-bold">{row.score}</span>
</div>
<div class="w-full bg-base-300 rounded-full h-2">
<div
class="h-2 rounded-full"
style={"width: #{row.score}%; background-color: #{score_bar_color(row.score)}"}
>
</div>
</div>
</div>
<% end %>
</div>
"""
end
defp score_bar_color(s), do: Microwaveprop.Format.propagation_tier_color(s)
defp factor_name(:humidity), do: "Humidity"
defp factor_name(:time_of_day), do: "Time of Day"
defp factor_name(:td_depression), do: "T-Td Depression"
defp factor_name(:refractivity), do: "Refractivity"
defp factor_name(:sky), do: "Sky Cover"
defp factor_name(:season), do: "Season"
defp factor_name(:wind), do: "Wind"
defp factor_name(:rain), do: "Rain"
defp factor_name(:pwat), do: "PWAT"
defp factor_name(:pressure), do: "Pressure"
defp elevation_data(result) do
raw_points = result.terrain.analysis.points
_ = BuildingsLoader.ensure_loaded_for_bbox(profile_bbox(raw_points))
# ElevationProfile hook expects: points with dist_km, elev, beam, r1, building_m
points =
Enum.map(raw_points, fn p ->
%{
dist_km: p.dist_km,
elev: p.elev,
beam: p[:beam] || 0,
r1: p[:r1] || 0,
building_m: BuildingsIndex.max_height_near(p.lat, p.lon, 80),
canopy_m: Canopy.lookup(p.lat, p.lon)
}
end)
%{
points: points,
freq_mhz: result.band_mhz,
dist_km: result.dist_km,
verdict: result.terrain.analysis.verdict,
ducts: []
}
end
defp profile_bbox([]), do: %{"south" => 0, "north" => 0, "west" => 0, "east" => 0}
defp profile_bbox(points) do
lats = Enum.map(points, & &1.lat)
lons = Enum.map(points, & &1.lon)
%{"south" => Enum.min(lats), "north" => Enum.max(lats), "west" => Enum.min(lons), "east" => Enum.max(lons)}
end
end