prop/lib/microwaveprop_web/live/contact_live/show.ex

1886 lines
66 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.ContactLive.Show do
@moduledoc false
use MicrowavepropWeb, :live_view
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Radio
alias Microwaveprop.Terrain
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Terrain.TerrainAnalysis
alias Microwaveprop.Weather
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
alias Microwaveprop.Workers.HrrrFetchWorker
alias Microwaveprop.Workers.SolarIndexWorker
alias Microwaveprop.Workers.TerrainProfileWorker
require Logger
@earth_radius_m 6_371_000.0
# T-Mobile CGNAT range — only enqueue enrichment jobs from this subnet
@enqueue_subnet {172, 56, 0, 0}
@enqueue_mask 13
@band_options BandConfig.band_options()
@mode_options ~w(CW SSB FM FT8 FT4 Q65)
@impl true
def mount(%{"id" => id}, session, socket) do
contact = id |> Radio.get_contact!() |> Radio.ensure_positions!()
can_enqueue = internal_network?(session)
if connected?(socket) do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:#{contact.id}")
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:hrrr")
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:weather")
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:solar")
end
weather = load_weather(contact)
solar = if can_enqueue, do: maybe_enqueue_solar(contact), else: load_solar(contact)
hrrr_path = Weather.hrrr_profiles_for_path(contact)
hrrr = List.first(hrrr_path)
{hrrr, contact} =
if can_enqueue, do: maybe_enqueue_hrrr(hrrr, contact), else: {hrrr, contact}
terrain = Terrain.get_terrain_profile(contact.id)
contact = if can_enqueue, do: maybe_enqueue_terrain(terrain, contact), else: contact
contact = if can_enqueue, do: maybe_enqueue_weather(weather, contact), else: contact
iemre = load_iemre(contact)
elevation_profile = compute_elevation_profile(contact, hrrr_path, weather.soundings)
propagation_analysis = build_propagation_analysis(contact, hrrr_path, terrain, elevation_profile, weather.soundings)
data_sources = build_data_sources(hrrr, hrrr_path, terrain, weather, elevation_profile)
{:ok,
assign(socket,
page_title: "#{contact.station1} / #{contact.station2}",
contact: contact,
surface_observations: weather.surface_observations,
soundings: weather.soundings,
solar: solar,
hrrr: hrrr,
iemre: iemre,
terrain: terrain,
elevation_profile: elevation_profile,
propagation_analysis: propagation_analysis,
data_sources: data_sources,
terrain_expanded: false,
hrrr_profile_expanded: false,
obs_sort_by: "station_name",
obs_sort_order: "asc",
sounding_sort_by: "station_name",
sounding_sort_order: "asc",
queue_counts: fetch_queue_counts(),
expanded_soundings: MapSet.new(),
editing: false,
edit_form: nil,
pending_edit: load_pending_edit(contact, socket),
band_options: @band_options,
mode_options: @mode_options
)}
end
defp load_pending_edit(contact, socket) do
case socket.assigns do
%{current_scope: %{user: %{id: user_id}}} ->
Radio.pending_edit_for_user(contact.id, user_id)
_ ->
nil
end
end
defp current_user(%{current_scope: %{user: %{} = user}}), do: user
defp current_user(_), do: nil
defp admin?(%{current_scope: %{user: %{is_admin: true}}}), do: true
defp admin?(_), do: false
@impl true
def handle_event("sort", %{"field" => field, "table" => "obs"}, socket) do
{sort_by, sort_order} =
toggle_sort(socket.assigns.obs_sort_by, socket.assigns.obs_sort_order, field)
sorted = sort_observations(socket.assigns.surface_observations, sort_by, sort_order)
{:noreply,
assign(socket,
surface_observations: sorted,
obs_sort_by: sort_by,
obs_sort_order: sort_order
)}
end
def handle_event("toggle_hrrr_profile", _params, socket) do
{:noreply, assign(socket, hrrr_profile_expanded: !socket.assigns.hrrr_profile_expanded)}
end
def handle_event("toggle_terrain", _params, socket) do
{:noreply, assign(socket, terrain_expanded: !socket.assigns.terrain_expanded)}
end
def handle_event("toggle_flag", _params, socket) do
contact = Radio.toggle_flagged_invalid!(socket.assigns.contact)
{:noreply, assign(socket, contact: contact)}
end
def handle_event("toggle_profile", %{"id" => id}, socket) do
expanded =
if MapSet.member?(socket.assigns.expanded_soundings, id) do
MapSet.delete(socket.assigns.expanded_soundings, id)
else
MapSet.put(socket.assigns.expanded_soundings, id)
end
{:noreply, assign(socket, expanded_soundings: expanded)}
end
def handle_event("sort", %{"field" => field, "table" => "soundings"}, socket) do
{sort_by, sort_order} =
toggle_sort(socket.assigns.sounding_sort_by, socket.assigns.sounding_sort_order, field)
sorted = sort_soundings(socket.assigns.soundings, sort_by, sort_order)
{:noreply,
assign(socket,
soundings: sorted,
sounding_sort_by: sort_by,
sounding_sort_order: sort_order
)}
end
def handle_event("toggle_edit", _params, socket) do
if socket.assigns.editing do
{:noreply, assign(socket, editing: false, edit_form: nil)}
else
contact = socket.assigns.contact
form_data = %{
"station1" => contact.station1,
"station2" => contact.station2,
"grid1" => contact.grid1,
"grid2" => contact.grid2,
"band" => if(contact.band, do: Decimal.to_string(contact.band), else: ""),
"mode" => contact.mode,
"qso_timestamp" =>
if(contact.qso_timestamp, do: Calendar.strftime(contact.qso_timestamp, "%Y-%m-%dT%H:%M"), else: "")
}
{:noreply, assign(socket, editing: true, edit_form: to_form(form_data, as: "edit"))}
end
end
def handle_event("validate_edit", %{"edit" => _params}, socket) do
{:noreply, socket}
end
def handle_event("submit_edit", %{"edit" => params}, socket) do
user = current_user(socket.assigns)
if is_nil(user) do
{:noreply, put_flash(socket, :error, "You must be logged in to edit.")}
else
contact = socket.assigns.contact
if admin?(socket.assigns) do
# Admin: apply changes directly
proposed = Radio.diff_against_contact(contact, Radio.normalize_proposed(params))
if proposed == %{} do
{:noreply, put_flash(socket, :error, "No changes detected.")}
else
Radio.apply_edit_to_contact(contact, proposed)
updated = Radio.get_contact!(contact.id)
{:noreply,
socket
|> assign(contact: updated, editing: false, edit_form: nil)
|> put_flash(:info, "Contact updated.")}
end
else
case Radio.create_contact_edit(contact, user, params) do
{:ok, _edit} ->
{:noreply,
socket
|> assign(editing: false, edit_form: nil, pending_edit: Radio.pending_edit_for_user(contact.id, user.id))
|> put_flash(:info, "Edit submitted for review.")}
{:error, changeset} ->
messages =
changeset
|> Ecto.Changeset.traverse_errors(fn {msg, _opts} -> msg end)
|> Enum.flat_map(fn {_field, msgs} -> msgs end)
|> Enum.join(", ")
{:noreply, put_flash(socket, :error, "Could not submit edit: #{messages}")}
end
end
end
end
@impl true
def handle_info({:terrain_ready, _contact_id}, socket) do
contact = %{socket.assigns.contact | terrain_status: :complete}
terrain = Terrain.get_terrain_profile(contact.id)
soundings = socket.assigns.soundings
hrrr_path = Weather.hrrr_profiles_for_path(contact)
elevation_profile = compute_elevation_profile(contact, hrrr_path, soundings)
propagation_analysis = build_propagation_analysis(contact, hrrr_path, terrain, elevation_profile, soundings)
{:noreply,
assign(socket,
contact: contact,
terrain: terrain,
elevation_profile: elevation_profile,
propagation_analysis: propagation_analysis
)}
end
def handle_info({:hrrr_ready, _info}, socket) do
contact = socket.assigns.contact
hrrr_path = Weather.hrrr_profiles_for_path(contact)
hrrr = List.first(hrrr_path)
if hrrr do
contact = %{contact | hrrr_status: :complete}
soundings = socket.assigns.soundings
terrain = socket.assigns.terrain
elevation_profile = compute_elevation_profile(contact, hrrr_path, soundings)
propagation_analysis = build_propagation_analysis(contact, hrrr_path, terrain, elevation_profile, soundings)
{:noreply,
assign(socket,
contact: contact,
hrrr: hrrr,
queue_counts: fetch_queue_counts(),
elevation_profile: elevation_profile,
propagation_analysis: propagation_analysis
)}
else
{:noreply, socket}
end
end
def handle_info({:weather_ready, _info}, socket) do
contact = %{socket.assigns.contact | weather_status: :complete}
weather = load_weather(contact)
soundings = weather.soundings
hrrr_path = Weather.hrrr_profiles_for_path(contact)
terrain = socket.assigns.terrain
elevation_profile = compute_elevation_profile(contact, hrrr_path, soundings)
propagation_analysis = build_propagation_analysis(contact, hrrr_path, terrain, elevation_profile, soundings)
{:noreply,
assign(socket,
contact: contact,
surface_observations: weather.surface_observations,
soundings: soundings,
elevation_profile: elevation_profile,
propagation_analysis: propagation_analysis
)}
end
def handle_info({:solar_ready, _date}, socket) do
solar = load_solar(socket.assigns.contact)
{:noreply, assign(socket, solar: solar)}
end
defp maybe_enqueue_weather(weather, contact) do
has_data = weather.surface_observations != [] or weather.soundings != []
cond do
has_data ->
if contact.weather_status != :complete do
Radio.set_enrichment_status!([contact.id], :weather_status, :complete)
end
%{contact | weather_status: :complete}
contact.pos1 && contact.weather_status != :queued ->
jobs = ContactWeatherEnqueueWorker.build_weather_jobs([contact])
if jobs == [] do
%{contact | weather_status: :unavailable}
else
Oban.insert_all(jobs)
Radio.set_enrichment_status!([contact.id], :weather_status, :queued)
%{contact | weather_status: :queued}
end
is_nil(contact.pos1) ->
%{contact | weather_status: :unavailable}
true ->
contact
end
end
defp toggle_sort(current_field, current_order, new_field) do
if current_field == new_field && current_order == "asc",
do: {new_field, "desc"},
else: {new_field, "asc"}
end
defp sort_observations(observations, sort_by, sort_order) do
sorter = obs_sort_key(sort_by)
sorted = Enum.sort_by(observations, sorter)
if sort_order == "desc", do: Enum.reverse(sorted), else: sorted
end
defp obs_sort_key("station_name"), do: fn obs -> obs.station.name || obs.station.station_code end
defp obs_sort_key("observed_at"), do: & &1.observed_at
defp obs_sort_key("temp_f"), do: &(&1.temp_f || 0)
defp obs_sort_key("dewpoint_f"), do: &(&1.dewpoint_f || 0)
defp obs_sort_key("relative_humidity"), do: &(&1.relative_humidity || 0)
defp obs_sort_key("sea_level_pressure_mb"), do: &(&1.sea_level_pressure_mb || 0)
defp obs_sort_key(_), do: fn obs -> obs.station.name || obs.station.station_code end
defp sort_soundings(soundings, sort_by, sort_order) do
sorter = sounding_sort_key(sort_by)
sorted = Enum.sort_by(soundings, sorter)
if sort_order == "desc", do: Enum.reverse(sorted), else: sorted
end
defp sounding_sort_key("station_name"), do: fn s -> s.station.name || s.station.station_code end
defp sounding_sort_key("observed_at"), do: & &1.observed_at
defp sounding_sort_key("surface_temp_c"), do: &(&1.surface_temp_c || 0)
defp sounding_sort_key("surface_refractivity"), do: &(&1.surface_refractivity || 0)
defp sounding_sort_key("k_index"), do: &(&1.k_index || 0)
defp sounding_sort_key("lifted_index"), do: &(&1.lifted_index || 0)
defp sounding_sort_key(_), do: fn s -> s.station.name || s.station.station_code end
defp load_iemre(contact) do
case Weather.iemre_for_contact(contact) do
%{hourly: hourly} = obs when is_list(hourly) and hourly != [] ->
qso_hour = contact.qso_timestamp.hour
nearest = Enum.min_by(hourly, fn h -> abs((h["utc_hour"] || 0) - qso_hour) end)
%{observation: obs, nearest_hour: nearest}
_ ->
nil
end
end
defp load_weather(contact) do
lat1 = contact.pos1 && contact.pos1["lat"]
lon1 = contact.pos1 && (contact.pos1["lon"] || contact.pos1["lng"])
lat2 = contact.pos2 && contact.pos2["lat"]
lon2 = contact.pos2 && (contact.pos2["lon"] || contact.pos2["lng"])
if lat1 && lon1 do
# Search along the path midpoint with radius covering both endpoints
mid_lat = if lat2, do: (lat1 + lat2) / 2, else: lat1
mid_lon = if lon2, do: (lon1 + lon2) / 2, else: lon1
half_dist = if lat2 && lon2, do: haversine_km(lat1, lon1, lat2, lon2) / 2, else: 0
radius = max(50, half_dist + 50)
weather =
Weather.weather_for_contact(
%{lat: mid_lat, lon: mid_lon, timestamp: contact.qso_timestamp},
radius_km: radius
)
# Keep only the 5 closest stations by distance to path midpoint
%{
surface_observations:
weather.surface_observations
|> Enum.sort_by(fn obs ->
slat = obs.station.lat
slon = obs.station.lon
abs(slat - mid_lat) + abs(slon - mid_lon)
end)
|> Enum.take(5),
soundings: weather.soundings
}
else
%{surface_observations: [], soundings: []}
end
end
defp maybe_enqueue_terrain(terrain, contact) when not is_nil(terrain) do
if contact.terrain_status != :complete do
Radio.set_enrichment_status!([contact.id], :terrain_status, :complete)
end
%{contact | terrain_status: :complete}
end
defp maybe_enqueue_terrain(nil, contact) do
cond do
contact.pos1 && contact.pos2 && contact.terrain_status != :queued ->
case Oban.insert(TerrainProfileWorker.new(%{"contact_id" => contact.id})) do
{:ok, %{conflict?: false}} ->
Radio.set_enrichment_status!([contact.id], :terrain_status, :queued)
%{contact | terrain_status: :queued}
_ ->
%{contact | terrain_status: :unavailable}
end
is_nil(contact.pos1) or is_nil(contact.pos2) ->
%{contact | terrain_status: :unavailable}
true ->
contact
end
end
defp maybe_enqueue_hrrr(hrrr, contact) when not is_nil(hrrr) do
if contact.hrrr_status != :complete do
Radio.set_enrichment_status!([contact.id], :hrrr_status, :complete)
end
{hrrr, %{contact | hrrr_status: :complete}}
end
defp maybe_enqueue_hrrr(nil, contact) do
lat = contact.pos1 && contact.pos1["lat"]
lon = contact.pos1 && (contact.pos1["lon"] || contact.pos1["lng"])
if lat && lon && contact.hrrr_status != :queued do
valid_time = HrrrClient.nearest_hrrr_hour(contact.qso_timestamp)
{rlat, rlon} = Weather.round_to_hrrr_grid(lat, lon)
case Oban.insert(
HrrrFetchWorker.new(%{
"lat" => rlat,
"lon" => rlon,
"valid_time" => DateTime.to_iso8601(valid_time)
})
) do
{:ok, %{conflict?: false}} ->
Radio.set_enrichment_status!([contact.id], :hrrr_status, :queued)
{nil, %{contact | hrrr_status: :queued}}
_ ->
# Unique conflict — job already ran recently, mark unavailable
Radio.set_enrichment_status!([contact.id], :hrrr_status, :unavailable)
{nil, %{contact | hrrr_status: :unavailable}}
end
else
{nil, contact}
end
end
defp queue_info(counts, queue) do
case Map.get(counts, queue, 0) do
0 -> ""
1 -> "(processing)"
n -> "(#{n} jobs ahead)"
end
end
defp fetch_queue_counts do
import Ecto.Query
from(j in Oban.Job,
where: j.state in ["available", "scheduled", "retryable", "executing"],
group_by: j.queue,
select: {j.queue, count(j.id)}
)
|> Microwaveprop.Repo.all()
|> Map.new()
end
defp internal_network?(session) do
case session["remote_ip"] do
nil ->
false
ip_str ->
case :inet.parse_address(String.to_charlist(ip_str)) do
{:ok, {a, b, c, d}} ->
ip_int = Bitwise.bsl(a, 24) + Bitwise.bsl(b, 16) + Bitwise.bsl(c, 8) + d
{sa, sb, sc, sd} = @enqueue_subnet
subnet_int = Bitwise.bsl(sa, 24) + Bitwise.bsl(sb, 16) + Bitwise.bsl(sc, 8) + sd
mask = Bitwise.bsl(0xFFFFFFFF, 32 - @enqueue_mask)
Bitwise.band(ip_int, mask) == Bitwise.band(subnet_int, mask)
_ ->
false
end
end
end
defp load_solar(contact) do
contact.qso_timestamp
|> DateTime.to_date()
|> Weather.get_solar_index()
end
defp maybe_enqueue_solar(contact) do
solar = load_solar(contact)
if solar do
solar
else
date = DateTime.to_date(contact.qso_timestamp)
Oban.insert(SolarIndexWorker.new(%{"date" => Date.to_iso8601(date)}))
nil
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>
<span class={[@contact.flagged_invalid && "line-through opacity-50"]}>
{@contact.station1} / {@contact.station2}
</span>
<:subtitle>
{Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}
<%= if @contact.flagged_invalid do %>
<span class="badge badge-error badge-sm ml-2">Flagged Invalid</span>
<% end %>
</:subtitle>
<:actions>
<%= if current_user(assigns) do %>
<button
phx-click="toggle_edit"
class={["btn btn-sm", if(@editing, do: "btn-ghost", else: "btn-outline")]}
>
<.icon
name={if @editing, do: "hero-x-mark", else: "hero-pencil-square"}
class="w-4 h-4"
/>
{if @editing, do: "Cancel", else: if(admin?(assigns), do: "Edit", else: "Suggest Edit")}
</button>
<% end %>
<button
phx-click="toggle_flag"
class={[
"btn btn-sm",
if(@contact.flagged_invalid, do: "btn-warning", else: "btn-ghost")
]}
>
<.icon name="hero-flag" class="w-4 h-4" />
{if @contact.flagged_invalid, do: "Unflag", else: "Flag as Invalid"}
</button>
<.link navigate={~p"/contacts"} class="btn btn-sm btn-ghost">
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to Contacts
</.link>
</:actions>
</.header>
<%= if @pending_edit && !@editing && !admin?(assigns) do %>
<div class="bg-info/10 border border-info/30 rounded-box px-4 py-2 text-sm mb-4">
<.icon name="hero-clock" class="w-4 h-4 inline" />
You have a pending edit for this contact awaiting admin review.
</div>
<% end %>
<%= if @editing do %>
<div class="bg-base-200 rounded-box p-4 mb-4">
<h3 class="font-semibold mb-3">{if admin?(assigns), do: "Edit Contact", else: "Suggest Edit"}</h3>
<p class="text-sm opacity-70 mb-4">
{if admin?(assigns), do: "Change any fields below. Changes will be applied immediately.", else: "Change any fields below. Only fields you modify will be submitted for review."}
</p>
<.form
for={@edit_form}
id="edit-form"
phx-change="validate_edit"
phx-submit="submit_edit"
class="space-y-4"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<.input field={@edit_form[:station1]} type="text" label="Station 1" />
<.input field={@edit_form[:grid1]} type="text" label="Grid 1" />
<.input field={@edit_form[:station2]} type="text" label="Station 2" />
<.input field={@edit_form[:grid2]} type="text" label="Grid 2" />
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<.input field={@edit_form[:band]} type="select" label="Band" options={@band_options} />
<.input field={@edit_form[:mode]} type="select" label="Mode" options={@mode_options} />
<.input
field={@edit_form[:qso_timestamp]}
type="datetime-local"
label="Timestamp (UTC)"
/>
</div>
<div class="flex gap-2">
<.button type="submit" class="btn btn-primary btn-sm">
<.icon name="hero-paper-airplane" class="w-4 h-4" />
{if admin?(assigns), do: "Save Changes", else: "Submit for Review"}
</.button>
<button type="button" phx-click="toggle_edit" class="btn btn-ghost btn-sm">
Cancel
</button>
</div>
</.form>
</div>
<% end %>
<%= if @contact.pos1 && @contact.pos2 do %>
<p class="text-xs text-base-content/50 italic mb-1">
Locations approximate, in the center of the grid squares
</p>
<div
id="contact-map"
phx-hook="ContactMap"
phx-update="ignore"
data-pos1={Jason.encode!(@contact.pos1)}
data-pos2={Jason.encode!(@contact.pos2)}
data-station1={@contact.station1}
data-station2={@contact.station2}
class="w-full h-64 rounded-lg border border-base-300 mb-4"
/>
<% end %>
<%= if !@elevation_profile && @contact.terrain_status == :queued do %>
<div class="flex items-center gap-2 text-sm text-base-content/50 my-4">
<span class="loading loading-spinner loading-sm"></span>
Computing elevation profile and terrain analysis {queue_info(@queue_counts, "terrain")}
</div>
<% end %>
<%= if @elevation_profile do %>
<div class="mb-4">
<div class="text-sm font-mono mb-2 text-center">
{format_band_ghz(@contact.band)} &middot; {@contact.mode} &middot; {format_dist(
@elevation_profile.dist_km
)} &middot; {@contact.grid1 || "—"} &rarr; {@contact.grid2 || "—"}
</div>
<table class="text-sm font-mono mb-2 mx-auto">
<tr>
<td class="pr-6">{@contact.station1} &rarr; {@contact.station2}</td>
<td class="pr-6">Az: {format_bearing(@elevation_profile.fwd_az)}</td>
<td>El: {@elevation_profile.fwd_el}&deg;</td>
</tr>
<tr>
<td class="pr-6">{@contact.station2} &rarr; {@contact.station1}</td>
<td class="pr-6">Az: {format_bearing(@elevation_profile.rev_az)}</td>
<td>El: {@elevation_profile.rev_el}&deg;</td>
</tr>
</table>
<%= if @elevation_profile.verdict == "BLOCKED" && @elevation_profile.first_obstruction_km do %>
<div class="bg-error/10 text-error font-semibold px-3 py-1 rounded mb-2 text-sm">
Obstructed at {format_dist(@elevation_profile.first_obstruction_km)}
</div>
<% end %>
<%= if @elevation_profile.verdict == "CLEAR" do %>
<div class="bg-success/10 text-success font-semibold px-3 py-1 rounded mb-2 text-sm">
Line of sight clear
</div>
<% end %>
<div
id="elevation-profile"
phx-hook="ElevationProfile"
phx-update="ignore"
data-profile={Jason.encode!(@elevation_profile)}
class="w-full h-96 border border-base-300 rounded-lg"
>
<canvas></canvas>
</div>
</div>
<% end %>
<%= if @propagation_analysis do %>
<div class="divider" />
<h2 class="text-base font-semibold mb-2">Propagation Analysis</h2>
<div class="text-sm mb-3">
<p>{@propagation_analysis.summary}</p>
<%= for detail <- @propagation_analysis.details do %>
<p class="mt-1 text-base-content/70">{detail}</p>
<% end %>
</div>
<%= if @propagation_analysis.factors do %>
<div class="overflow-x-auto mb-2">
<table class="table table-xs table-zebra">
<thead>
<tr>
<th>Factor</th>
<th>Score</th>
<th>Weight</th>
<th>Contribution</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<%= for f <- @propagation_analysis.factor_rows do %>
<tr>
<td class="font-semibold">{f.name}</td>
<td>{f.score}/100</td>
<td>{f.weight}%</td>
<td>{f.contribution}</td>
<td class="text-base-content/60">{f.note}</td>
</tr>
<% end %>
</tbody>
<tfoot>
<tr class="font-semibold">
<td>Composite</td>
<td colspan="2">{@propagation_analysis.composite_score}/100</td>
<td colspan="2">
<span class={propagation_tier_class(@propagation_analysis.composite_score)}>
{propagation_tier_label(@propagation_analysis.composite_score)}
</span>
</td>
</tr>
</tfoot>
</table>
</div>
<% end %>
<% end %>
<div class="divider" />
<h2 class="text-base font-semibold mb-2" id="terrain-heading">Terrain Profile</h2>
<%= if @terrain do %>
<div class="mb-6 border border-base-300 rounded-lg" id="terrain-profile">
<button
phx-click="toggle_terrain"
class="w-full flex items-center justify-between p-4 hover:bg-base-200 transition-colors"
>
<div class="flex items-center gap-4 text-sm flex-wrap">
<span class={[
"badge badge-sm",
terrain_verdict_class(@terrain.verdict)
]}>
{@terrain.verdict}
</span>
<span>{@terrain.sample_count} samples</span>
<span>Max elev: {format_number(@terrain.max_elevation_m)} m</span>
<span>Min clearance: {format_number(@terrain.min_clearance_m)} m</span>
<%= if @terrain.diffraction_db && @terrain.diffraction_db > 0 do %>
<span>Diffraction: {format_number(@terrain.diffraction_db)} dB</span>
<% end %>
<%= if @terrain.obstructed_count && @terrain.obstructed_count > 0 do %>
<span class="text-error">{@terrain.obstructed_count} obstructed</span>
<% end %>
<%= if @terrain.fresnel_hit_count && @terrain.fresnel_hit_count > 0 do %>
<span class="text-warning">{@terrain.fresnel_hit_count} Fresnel hits</span>
<% end %>
</div>
<.icon
name={if @terrain_expanded, do: "hero-chevron-up", else: "hero-chevron-down"}
class="w-5 h-5"
/>
</button>
<%= if @terrain_expanded do %>
<div class="px-4 pb-4">
<div class="overflow-x-auto">
<table class="table table-xs table-zebra">
<thead>
<tr>
<th>#</th>
<th>Lat</th>
<th>Lon</th>
<th>Dist (km)</th>
<th>Elev (m)</th>
</tr>
</thead>
<tbody>
<%= for {pt, idx} <- Enum.with_index(@terrain.path_points) do %>
<tr>
<td>{idx}</td>
<td>{format_number(pt["lat"])}</td>
<td>{format_number(pt["lon"])}</td>
<td>{format_number(pt["dist_km"])}</td>
<td>{format_number(pt["elev"])}</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="text-sm text-base-content/50 italic">
<%= if @contact.terrain_status == :queued do %>
<span class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span>
Computing terrain profile {queue_info(@queue_counts, "terrain")}
</span>
<% else %>
No terrain profile available.
<% end %>
</div>
<% end %>
<div class="divider" />
<h2 class="text-base font-semibold mb-2">Soundings</h2>
<%= if @soundings == [] do %>
<div class="text-sm text-base-content/50 italic">
<%= if @contact.weather_status == :queued do %>
<span class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span>
Fetching sounding data from nearby stations {queue_info(@queue_counts, "weather")}
</span>
<% else %>
No soundings found nearby.
<% end %>
</div>
<% else %>
<%= for s <- @soundings do %>
<div class="mb-6 border border-base-300 rounded-lg">
<button
phx-click="toggle_profile"
phx-value-id={s.id}
class="w-full flex items-center justify-between p-4 hover:bg-base-200 transition-colors"
>
<div class="flex items-center gap-4 text-sm">
<span class="font-semibold">{s.station.name || s.station.station_code}</span>
<span>{Calendar.strftime(s.observed_at, "%H:%M UTC")}</span>
<span>{s.level_count} levels</span>
<span>Sfc: {format_number(s.surface_temp_c)}°C</span>
<span>N: {format_number(s.surface_refractivity)}</span>
<span>K: {format_number(s.k_index)}</span>
<span>LI: {format_number(s.lifted_index)}</span>
<%= if s.ducting_detected do %>
<span class="badge badge-warning badge-sm">Ducting</span>
<% end %>
</div>
<.icon
name={
if MapSet.member?(@expanded_soundings, s.id),
do: "hero-chevron-up",
else: "hero-chevron-down"
}
class="w-5 h-5"
/>
</button>
<%= if MapSet.member?(@expanded_soundings, s.id) do %>
<div class="px-4 pb-4">
<div class="overflow-x-auto">
<table class="table table-xs table-zebra">
<thead>
<tr>
<th>Pressure (mb)</th>
<th>Height (m)</th>
<th>Temp (°C)</th>
<th>Dewpoint (°C)</th>
<th>Wind Dir (°)</th>
<th>Wind Spd (kts)</th>
</tr>
</thead>
<tbody>
<%= for level <- filter_profile(s.profile) do %>
<tr>
<td>{format_number(level["pres"])}</td>
<td>{format_number(level["hght"])}</td>
<td>{format_number(level["tmpc"])}</td>
<td>{format_number(level["dwpc"])}</td>
<td>{format_number(level["drct"])}</td>
<td>{format_number(level["sknt"])}</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
</div>
<% end %>
<% end %>
<div class="divider" />
<h2 class="text-base font-semibold mb-2">Solar Conditions</h2>
<%= if @solar do %>
<div class="flex flex-wrap gap-x-6 gap-y-1 text-sm">
<div><span class="font-semibold">SFI:</span> {@solar.sfi || "—"}</div>
<div><span class="font-semibold">Sunspot #:</span> {@solar.sunspot_number || "—"}</div>
<div><span class="font-semibold">Ap:</span> {@solar.ap_index || "—"}</div>
<div><span class="font-semibold">Kp:</span> {format_kp(@solar.kp_values)}</div>
</div>
<% else %>
<div class="text-sm text-base-content/50 italic">
<span class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span>
Fetching solar index data {queue_info(@queue_counts, "solar")}
</span>
</div>
<% end %>
<div class="divider" />
<h2 class="text-base font-semibold mb-2">HRRR Model Profile</h2>
<%= if @hrrr do %>
<div class="mb-6 border border-base-300 rounded-lg">
<button
phx-click="toggle_hrrr_profile"
class="w-full flex items-center justify-between p-4 hover:bg-base-200 transition-colors"
>
<div class="flex items-center gap-4 text-sm">
<span class="font-semibold">
{format_number(@hrrr.lat)}°, {format_number(@hrrr.lon)}°
</span>
<span>{Calendar.strftime(@hrrr.valid_time, "%H:%M UTC")}</span>
<span>HPBL: {format_number(@hrrr.hpbl_m)} m</span>
<span>PWAT: {format_number(@hrrr.pwat_mm)} mm</span>
<span>N: {format_number(@hrrr.surface_refractivity)}</span>
<span>dN/dh: {format_number(@hrrr.min_refractivity_gradient)}</span>
<%= if @hrrr.ducting_detected do %>
<span class="badge badge-warning badge-sm">Ducting</span>
<% end %>
</div>
<.icon
name={if @hrrr_profile_expanded, do: "hero-chevron-up", else: "hero-chevron-down"}
class="w-5 h-5"
/>
</button>
<%= if @hrrr_profile_expanded do %>
<div class="px-4 pb-4">
<div class="grid grid-cols-2 gap-x-8 gap-y-1 text-sm mb-4">
<div>Surface Temp: {format_number(@hrrr.surface_temp_c)}°C</div>
<div>Surface Dewpoint: {format_number(@hrrr.surface_dewpoint_c)}°C</div>
<div>Surface Pressure: {format_number(@hrrr.surface_pressure_mb)} mb</div>
<div>
Run Time: {if @hrrr.run_time,
do: Calendar.strftime(@hrrr.run_time, "%Y-%m-%d %H:%M UTC"),
else: "—"}
</div>
</div>
<%= if @hrrr.profile && @hrrr.profile != [] do %>
<div class="overflow-x-auto">
<table class="table table-xs table-zebra">
<thead>
<tr>
<th>Pressure (mb)</th>
<th>Height (m)</th>
<th>Temp (°C)</th>
<th>Dewpoint (°C)</th>
</tr>
</thead>
<tbody>
<%= for level <- @hrrr.profile do %>
<tr>
<td>{format_number(level["pres"])}</td>
<td>{format_number(level["hght"])}</td>
<td>{format_number(level["tmpc"])}</td>
<td>{format_number(level["dwpc"])}</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
<% end %>
</div>
<% else %>
<div class="text-sm text-base-content/50 italic">
<%= case @contact.hrrr_status do %>
<% :queued -> %>
<span class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span>
Fetching HRRR atmospheric data {queue_info(@queue_counts, "hrrr")}
</span>
<% :unavailable -> %>
HRRR data unavailable for this contact time.
<% _ -> %>
No HRRR profile available.
<% end %>
</div>
<% end %>
<div class="divider" />
<h2 class="text-base font-semibold mb-2">IEMRE Gridded Reanalysis</h2>
<%= if @iemre do %>
<% h = @iemre.nearest_hour %>
<% obs = @iemre.observation %>
<div class="text-xs opacity-60 mb-2">
Grid point: {format_number(obs.lat)}°, {format_number(obs.lon)}° &mdash; {obs.date} hour {h[
"utc_hour"
] || "—"} UTC
</div>
<div class="flex flex-wrap gap-x-6 gap-y-1 text-sm">
<div><span class="font-semibold">Temp:</span> {format_number(h["air_temp_f"])}°F</div>
<div><span class="font-semibold">Dewpoint:</span> {format_number(h["dew_point_f"])}°F</div>
<div><span class="font-semibold">Sky Cover:</span> {format_number(h["skyc_%"])}%</div>
<div>
<span class="font-semibold">Wind:</span>
{format_number(wind_speed_from_components(h["uwnd_mps"], h["vwnd_mps"]))} kts
</div>
<div>
<span class="font-semibold">Precip:</span> {format_number(h["hourly_precip_in"])}"
</div>
<div :if={h["soil4t_f"]}>
<span class="font-semibold">Soil Temp:</span> {format_number(h["soil4t_f"])}°F
</div>
</div>
<% else %>
<div class="text-sm text-base-content/50 italic">
<%= case @contact.iemre_status do %>
<% :queued -> %>
<span class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span> Fetching IEMRE data
</span>
<% :unavailable -> %>
IEMRE data unavailable for this contact.
<% _ -> %>
No IEMRE data available.
<% end %>
</div>
<% end %>
<div class="divider" />
<h2 class="text-base font-semibold mb-2">Surface Observations</h2>
<%= if @surface_observations == [] do %>
<div class="text-sm text-base-content/50 italic">
<%= if @contact.weather_status == :queued do %>
<span class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span>
Fetching surface observations from nearby stations {queue_info(@queue_counts, "weather")}...
</span>
<% else %>
No surface observations found nearby.
<% end %>
</div>
<% else %>
<div class="overflow-x-auto">
<table class="table table-xs table-zebra">
<thead>
<tr>
<th>Station</th>
<th>Time</th>
<th>Temp</th>
<th>Dewpt</th>
<th>RH%</th>
<th>Wind</th>
<th>Pres (mb)</th>
<th>Sky</th>
</tr>
</thead>
<tbody>
<%= for obs <- @surface_observations do %>
<tr>
<td>{obs.station.name || obs.station.station_code}</td>
<td>{Calendar.strftime(obs.observed_at, "%H:%M")}</td>
<td>{obs.temp_f || "—"}</td>
<td>{obs.dewpoint_f || "—"}</td>
<td>{format_number(obs.relative_humidity)}</td>
<td>{format_wind(obs)}</td>
<td>{obs.sea_level_pressure_mb || "—"}</td>
<td>{obs.sky_condition || "—"}</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<%= if @data_sources do %>
<div class="divider" />
<h2 class="text-base font-semibold mb-2">Data Sources</h2>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 text-xs">
<div class="bg-base-200 rounded-box p-3">
<div class="font-semibold mb-1">HRRR Model</div>
<%= if @data_sources.hrrr do %>
<div>{@data_sources.hrrr.profile_count} path profiles</div>
<div>{@data_sources.hrrr.levels} pressure levels each</div>
<div>Valid: {@data_sources.hrrr.valid_time}</div>
<div>Grid: {@data_sources.hrrr.lat}, {@data_sources.hrrr.lon}</div>
<% else %>
<div class="opacity-50">Not available</div>
<% end %>
</div>
<div class="bg-base-200 rounded-box p-3">
<div class="font-semibold mb-1">Elevation Profile</div>
<%= if @data_sources.elevation do %>
<div>{@data_sources.elevation.samples} samples along path</div>
<div>{@data_sources.elevation.dist} path distance</div>
<div>Source: {@data_sources.elevation.source}</div>
<div>{@data_sources.elevation.duct_count} duct layers detected</div>
<% else %>
<div class="opacity-50">Not available</div>
<% end %>
</div>
<div class="bg-base-200 rounded-box p-3">
<div class="font-semibold mb-1">Terrain Analysis</div>
<%= if @data_sources.terrain do %>
<div>{@data_sources.terrain.samples} profile samples</div>
<div>Verdict: {@data_sources.terrain.verdict}</div>
<div>Max elevation: {@data_sources.terrain.max_elev} m</div>
<div>Diffraction: {@data_sources.terrain.diffraction} dB</div>
<% else %>
<div class="opacity-50">Not available</div>
<% end %>
</div>
<div class="bg-base-200 rounded-box p-3">
<div class="font-semibold mb-1">Surface Observations</div>
<div>{length(@surface_observations)} stations</div>
<%= if @surface_observations != [] do %>
<div>
{Calendar.strftime(hd(@surface_observations).observed_at, "%H:%M")} {Calendar.strftime(
List.last(@surface_observations).observed_at,
"%H:%M"
)} UTC
</div>
<% end %>
</div>
<div class="bg-base-200 rounded-box p-3">
<div class="font-semibold mb-1">Soundings (RAOB)</div>
<div>{length(@soundings)} profiles</div>
<%= if @soundings != [] do %>
<div>
{Enum.map_join(@soundings, ", ", fn s ->
"#{s.station.station_code} (#{s.level_count} levels)"
end)}
</div>
<div>{Enum.count(@soundings, & &1.ducting_detected)} with ducting</div>
<% end %>
</div>
<div class="bg-base-200 rounded-box p-3">
<div class="font-semibold mb-1">Solar Indices</div>
<%= if @solar do %>
<div>SFI: {@solar.sfi || "—"}</div>
<div>SSN: {@solar.sunspot_number || "—"}</div>
<div>Ap: {@solar.ap_index || "—"}</div>
<% else %>
<div class="opacity-50">Not available</div>
<% end %>
</div>
</div>
<% end %>
</Layouts.app>
"""
end
defp format_number(nil), do: ""
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 wind_speed_from_components(u, v) when is_number(u) and is_number(v) do
mps = :math.sqrt(u * u + v * v)
Float.round(mps * 1.944, 1)
end
defp wind_speed_from_components(_, _), do: nil
defp format_dist(km) do
mi = km * 0.621371
"#{:erlang.float_to_binary(mi, decimals: 1)} mi (#{:erlang.float_to_binary(km, decimals: 1)} km)"
end
defp format_bearing(deg) do
whole = trunc(deg)
frac = round((deg - whole) * 10)
"#{String.pad_leading(to_string(whole), 3, "0")}.#{frac}\u00B0"
end
defp format_band_ghz(nil), do: ""
defp format_band_ghz(band) do
mhz = Decimal.to_float(band)
ghz = mhz / 1000
if ghz == Float.round(ghz, 0) do
"#{trunc(ghz)} GHz"
else
"#{:erlang.float_to_binary(ghz, decimals: 1)} GHz"
end
end
defp format_wind(obs) do
case {obs.wind_direction_deg, obs.wind_speed_kts} do
{nil, nil} -> ""
{dir, speed} -> "#{dir || "?"}° @ #{speed || "?"} kts"
end
end
defp filter_profile(profile) do
Enum.filter(profile, fn level ->
level["tmpc"] != nil || level["dwpc"] != nil
end)
end
defp format_kp(nil), do: ""
defp format_kp(values) when is_list(values), do: Enum.map_join(values, ", ", &format_number/1)
defp terrain_verdict_class("CLEAR"), do: "badge-success"
defp terrain_verdict_class("FRESNEL_MINOR"), do: "badge-warning"
defp terrain_verdict_class("FRESNEL_PARTIAL"), do: "badge-warning"
defp terrain_verdict_class("BLOCKED"), do: "badge-error"
defp terrain_verdict_class(_), do: "badge-ghost"
defp compute_elevation_profile(contact, hrrr_path, soundings) do
with %{"lat" => lat1} <- contact.pos1,
lon1 when is_number(lon1) <- contact.pos1["lon"] || contact.pos1["lng"],
%{"lat" => lat2} <- contact.pos2,
lon2 when is_number(lon2) <- contact.pos2["lon"] || contact.pos2["lng"],
{:ok, profile} <- safe_fetch_elevation(lat1, lon1, lat2, lon2) do
freq_ghz = band_to_ghz(contact.band)
dist_km = haversine_km(lat1, lon1, lat2, lon2)
analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz)
fwd_az = initial_bearing(lat1, lon1, lat2, lon2)
rev_az = initial_bearing(lat2, lon2, lat1, lon1)
tx_elev = hd(profile).elev
rx_elev = List.last(profile).elev
fwd_el = elevation_angle(tx_elev, rx_elev, dist_km * 1000)
rev_el = elevation_angle(rx_elev, tx_elev, dist_km * 1000)
first_obs =
case Enum.find(analysis.points, & &1.obstructed) do
nil -> nil
p -> p.dist_km
end
ducts =
hrrr_path
|> extract_ducts(soundings, tx_elev)
|> merge_nearby_ducts()
|> Enum.sort_by(& &1.strength, :desc)
|> Enum.take(3)
|> mark_likely_duct(tx_elev, rx_elev)
%{
points:
Enum.map(analysis.points, fn p ->
%{elev: p.elev, beam: p.beam, r1: p.r1, dist_km: p.dist_km}
end),
freq_mhz: freq_ghz * 1000,
dist_km: dist_km,
k_factor: analysis.k_factor,
fwd_az: fwd_az,
rev_az: rev_az,
fwd_el: fwd_el,
rev_el: rev_el,
verdict: analysis.verdict,
first_obstruction_km: first_obs,
ducts: ducts
}
else
_ -> nil
end
end
# Extract duct layers from best available source across all HRRR path profiles:
# 1. HRRR explicit ducts (M-profile detected) from any path point
# 2. Sounding explicit ducts (high vertical resolution, most reliable)
# 3. Inferred from strongest HRRR refractivity gradient along path
defp extract_ducts(hrrr_path, soundings, surface_elev) do
hrrr_ducts = extract_hrrr_ducts(hrrr_path, surface_elev)
if hrrr_ducts == [] do
sounding_ducts = extract_sounding_ducts(soundings, surface_elev)
if sounding_ducts == [] do
# Use the profile with the strongest (most negative) gradient
best =
hrrr_path
|> Enum.filter(&is_number(&1.min_refractivity_gradient))
|> Enum.min_by(& &1.min_refractivity_gradient, fn -> nil end)
infer_duct_from_gradient(best, surface_elev)
else
sounding_ducts
end
else
hrrr_ducts
end
end
defp extract_hrrr_ducts(hrrr_path, surface_elev) when is_list(hrrr_path) do
hrrr_path
|> Enum.flat_map(fn h ->
case h.duct_characteristics do
ducts when is_list(ducts) and ducts != [] ->
Enum.map(ducts, fn d ->
%{
base_m_msl: surface_elev + (d["base"] || 0),
top_m_msl: surface_elev + (d["top"] || 0),
strength: d["strength"],
source: "hrrr"
}
end)
_ ->
[]
end
end)
|> Enum.uniq_by(fn d -> {round(d.base_m_msl), round(d.top_m_msl)} end)
end
defp extract_sounding_ducts(soundings, surface_elev) when is_list(soundings) do
soundings
|> Enum.filter(& &1.ducting_detected)
|> Enum.flat_map(fn s ->
case s.duct_characteristics do
ducts when is_list(ducts) and ducts != [] ->
Enum.map(ducts, fn d ->
%{
base_m_msl: surface_elev + (d["base"] || 0),
top_m_msl: surface_elev + (d["top"] || 0),
strength: d["strength"],
source: "sounding"
}
end)
_ ->
[]
end
end)
|> Enum.uniq_by(fn d -> {round(d.base_m_msl), round(d.top_m_msl)} end)
end
defp extract_sounding_ducts(_, _), do: []
# HRRR pressure levels are too coarse (~250m) to detect thin ducting layers
# via the M-profile method. When min_refractivity_gradient indicates enhanced
# propagation, infer a duct layer within the boundary layer.
defp infer_duct_from_gradient(nil, _surface_elev), do: []
defp infer_duct_from_gradient(hrrr, surface_elev) do
grad = hrrr.min_refractivity_gradient
hpbl = hrrr.hpbl_m
if is_number(grad) and grad < -100 and is_number(hpbl) and hpbl > 0 do
duct_top = min(hpbl * 0.6, 500)
strength = abs(grad) / 10
[
%{
base_m_msl: surface_elev,
top_m_msl: surface_elev + duct_top,
strength: Float.round(strength, 1),
source: "inferred"
}
]
else
[]
end
end
# Merge duct layers that overlap or are within 100m of each other
defp merge_nearby_ducts(ducts) do
ducts
|> Enum.sort_by(& &1.base_m_msl)
|> Enum.reduce([], fn duct, acc ->
case acc do
[prev | rest] when duct.base_m_msl <= prev.top_m_msl + 100 ->
merged = %{
prev
| top_m_msl: max(prev.top_m_msl, duct.top_m_msl),
strength: max(prev.strength, duct.strength)
}
[merged | rest]
_ ->
[duct | acc]
end
end)
|> Enum.reverse()
end
# Mark the duct most likely carrying the signal. The signal enters at
# surface level from each end, so the duct whose base is closest to
# the average endpoint elevation is the most probable propagation path.
defp mark_likely_duct([], _tx_elev, _rx_elev), do: []
defp mark_likely_duct(ducts, tx_elev, rx_elev) do
avg_elev = (tx_elev + rx_elev) / 2
{likely_idx, _} =
ducts
|> Enum.with_index()
|> Enum.min_by(fn {d, _idx} ->
# Distance from average endpoint elevation to the duct layer
# Prefer ducts that the endpoints are actually inside of
if avg_elev >= d.base_m_msl and avg_elev <= d.top_m_msl do
-d.strength
else
min(abs(d.base_m_msl - avg_elev), abs(d.top_m_msl - avg_elev))
end
end)
Enum.with_index(ducts, fn d, i ->
Map.put(d, :likely, i == likely_idx)
end)
end
defp safe_fetch_elevation(lat1, lon1, lat2, lon2) do
ElevationClient.fetch_elevation_profile(lat1, lon1, lat2, lon2, 256)
rescue
e ->
Logger.warning("Elevation profile failed: #{Exception.message(e)}")
{:error, :elevation_unavailable}
end
defp band_to_ghz(nil), do: 10.0
defp band_to_ghz(band) do
band
|> Decimal.to_float()
|> Kernel./(1000)
end
defp haversine_km(lat1, lon1, lat2, lon2) do
dlat = deg_to_rad(lat2 - lat1)
dlon = deg_to_rad(lon2 - lon1)
rlat1 = deg_to_rad(lat1)
rlat2 = deg_to_rad(lat2)
a =
:math.sin(dlat / 2) ** 2 +
:math.cos(rlat1) * :math.cos(rlat2) * :math.sin(dlon / 2) ** 2
2 * 6371.0 * :math.asin(:math.sqrt(a))
end
defp initial_bearing(lat1, lon1, lat2, lon2) do
rlat1 = deg_to_rad(lat1)
rlat2 = deg_to_rad(lat2)
dlon = deg_to_rad(lon2 - lon1)
y = :math.sin(dlon) * :math.cos(rlat2)
x =
:math.cos(rlat1) * :math.sin(rlat2) -
:math.sin(rlat1) * :math.cos(rlat2) * :math.cos(dlon)
bearing = y |> :math.atan2(x) |> rad_to_deg()
Float.round(rem_float(bearing + 360, 360), 1)
end
defp elevation_angle(h_tx, h_rx, dist_m) do
k_r = 4 / 3 * @earth_radius_m
angle = :math.atan2(h_rx - h_tx, dist_m) - dist_m / (2 * k_r)
Float.round(rad_to_deg(angle), 2)
end
defp deg_to_rad(deg), do: deg * :math.pi() / 180
defp rad_to_deg(rad), do: rad * 180 / :math.pi()
defp rem_float(a, b), do: a - Float.floor(a / b) * b
# ── Data sources summary ────────────────────────────────────────
defp build_data_sources(hrrr, hrrr_path, terrain, weather, elevation_profile) do
%{
hrrr: build_hrrr_source(hrrr, hrrr_path),
elevation: build_elevation_source(elevation_profile),
terrain: build_terrain_source(terrain),
obs_count: length(weather.surface_observations),
sounding_count: length(weather.soundings)
}
end
defp build_hrrr_source(nil, _), do: nil
defp build_hrrr_source(hrrr, hrrr_path) do
levels = if hrrr.profile, do: length(hrrr.profile), else: 0
%{
profile_count: length(hrrr_path),
levels: levels,
valid_time: Calendar.strftime(hrrr.valid_time, "%Y-%m-%d %H:%M UTC"),
lat: :erlang.float_to_binary(hrrr.lat / 1, decimals: 2),
lon: :erlang.float_to_binary(hrrr.lon / 1, decimals: 2)
}
end
defp build_elevation_source(nil), do: nil
defp build_elevation_source(ep) do
%{
samples: length(ep.points),
dist: format_dist(ep.dist_km),
source: "SRTM 1-arcsecond",
duct_count: length(ep.ducts || [])
}
end
defp build_terrain_source(nil), do: nil
defp build_terrain_source(terrain) do
%{
samples: terrain.sample_count,
verdict: terrain.verdict,
max_elev: :erlang.float_to_binary((terrain.max_elevation_m || 0) / 1, decimals: 0),
diffraction: :erlang.float_to_binary((terrain.diffraction_db || 0) / 1, decimals: 1)
}
end
# ── Propagation analysis ──────────────────────────────────────────
defp build_propagation_analysis(contact, hrrr_path, terrain, elevation_profile, soundings) do
band_mhz = if contact.band, do: Decimal.to_integer(contact.band), else: 10_000
band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000)
dist_km = contact.distance_km && Decimal.to_float(contact.distance_km)
hrrr = List.first(hrrr_path)
{factors, factor_rows, composite} = compute_factors(hrrr_path, contact, band_config)
summary = build_summary(terrain, elevation_profile, hrrr, soundings, dist_km, band_mhz)
details = build_details(hrrr, soundings, elevation_profile, band_mhz)
%{
summary: summary,
details: details,
factors: factors,
factor_rows: factor_rows,
composite_score: composite
}
end
defp compute_factors([], _contact, _band_config), do: {nil, [], nil}
defp compute_factors(_hrrr_path, %{pos1: nil}, _band_config), do: {nil, [], nil}
defp compute_factors(hrrr_path, contact, band_config) do
conditions = Scorer.path_integrated_conditions(hrrr_path, contact)
if is_nil(conditions) do
{nil, [], nil}
else
result = Scorer.composite_score(conditions, band_config)
weights = BandConfig.weights()
factor_rows =
[
{:humidity, "Humidity", humidity_note(conditions.abs_humidity, band_config)},
{:time_of_day, "Time of Day", time_note(contact.qso_timestamp, conditions.longitude)},
{:td_depression, "T-Td Depression", td_note(conditions.temp_f, conditions.dewpoint_f)},
{:refractivity, "Refractivity", refractivity_note(conditions.min_refractivity_gradient)},
{:sky, "Sky Cover", sky_note(conditions.sky_cover_pct)},
{:season, "Season", season_note(contact.qso_timestamp.month, band_config)},
{:wind, "Wind", wind_note(conditions.wind_speed_kts)},
{:rain, "Rain", rain_note(conditions.rain_rate_mmhr)},
{:pwat, "PWAT", pwat_note(conditions.pwat_mm, band_config)},
{:pressure, "Pressure", pressure_note(conditions.pressure_mb)}
]
|> Enum.map(fn {key, name, note} ->
score = result.factors[key]
weight = weights[key]
%{
name: name,
score: score,
weight: round(weight * 100),
contribution: Float.round(score * weight, 1),
note: note
}
end)
|> Enum.sort_by(& &1.contribution, :desc)
{result.factors, factor_rows, result.score}
end
end
defp build_summary(terrain, elevation_profile, hrrr, soundings, dist_km, band_mhz) do
terrain_status = terrain_summary(terrain, elevation_profile)
mechanism = propagation_mechanism(terrain, elevation_profile, hrrr, soundings, dist_km)
band_note = band_summary(band_mhz, hrrr)
[terrain_status, mechanism, band_note]
|> Enum.reject(&is_nil/1)
|> Enum.join(" ")
end
defp terrain_summary(nil, nil), do: "No terrain data available."
defp terrain_summary(terrain, elevation_profile) do
verdict = (elevation_profile && elevation_profile.verdict) || (terrain && terrain.verdict)
case verdict do
"CLEAR" ->
"Line of sight is clear between stations."
"FRESNEL_MINOR" ->
"Line of sight is clear but with minor Fresnel zone encroachment."
"FRESNEL_PARTIAL" ->
"Line of sight has partial Fresnel zone obstruction."
"BLOCKED" ->
obs_dist = elevation_profile && elevation_profile.first_obstruction_km
if obs_dist do
"Path is terrain-obstructed at #{:erlang.float_to_binary(obs_dist * 0.621371, decimals: 1)} mi (#{:erlang.float_to_binary(obs_dist, decimals: 1)} km) from station 1."
else
"Path is terrain-obstructed."
end
_ ->
nil
end
end
defp propagation_mechanism(terrain, elevation_profile, hrrr, soundings, dist_km) do
blocked? = terrain && terrain.verdict == "BLOCKED"
ducting? = has_ducting?(hrrr, soundings)
enhanced_refraction? =
hrrr != nil && is_number(hrrr.min_refractivity_gradient) && hrrr.min_refractivity_gradient < -100
long_path? = dist_km && dist_km > 100
ducts = (elevation_profile && elevation_profile.ducts) || []
likely_duct = Enum.find(ducts, & &1[:likely])
cond do
blocked? && ducting? && likely_duct ->
"Signal likely propagated via atmospheric ducting (#{duct_description(likely_duct)}), bending over the terrain obstruction."
blocked? && ducting? ->
"Ducting conditions detected — signal likely propagated through a tropospheric duct, bypassing the terrain obstruction."
blocked? && enhanced_refraction? ->
grad = round(hrrr.min_refractivity_gradient)
"Enhanced refraction (dN/dh = #{grad} N-units/km) likely bent the signal over the obstruction. Conditions are super-refractive but below full ducting threshold."
blocked? ->
diff_db = terrain && terrain.diffraction_db
if diff_db && diff_db > 0 do
"Path is obstructed with #{:erlang.float_to_binary(diff_db, decimals: 1)} dB diffraction loss. Contact may have been enabled by knife-edge diffraction or tropospheric scatter."
else
"Path is obstructed. Contact likely enabled by tropospheric scatter or transient ducting conditions."
end
ducting? && long_path? && likely_duct ->
"Ducting conditions present (#{duct_description(likely_duct)}) — likely extending range beyond normal line of sight."
ducting? && long_path? ->
"Ducting conditions present — likely contributing to extended range."
enhanced_refraction? && long_path? ->
"Enhanced refraction present — may have contributed to extended range."
true ->
nil
end
end
defp duct_description(%{source: source, base_m_msl: base, top_m_msl: top, strength: strength}) do
base_ft = round(base * 3.28084)
top_ft = round(top * 3.28084)
src =
case source do
"sounding" -> "sounding-detected"
"inferred" -> "estimated"
_ -> "detected"
end
"#{src} layer at #{base_ft}-#{top_ft} ft, #{strength} M-units"
end
defp duct_description(_), do: "detected layer"
defp has_ducting?(hrrr, soundings) do
hrrr_ducting = hrrr && hrrr.ducting_detected
sounding_ducting = is_list(soundings) && Enum.any?(soundings, & &1.ducting_detected)
hrrr_ducting || sounding_ducting
end
defp band_summary(band_mhz, hrrr) when band_mhz <= 12_000 do
if hrrr && is_number(hrrr.pwat_mm) && hrrr.pwat_mm > 20 do
"At 10 GHz, the elevated moisture (PWAT #{:erlang.float_to_binary(hrrr.pwat_mm, decimals: 1)} mm) enhances refractivity and ducting potential."
end
end
defp band_summary(band_mhz, hrrr) when band_mhz >= 24_000 do
if hrrr && is_number(hrrr.pwat_mm) && hrrr.pwat_mm > 25 do
"At #{round(band_mhz / 1000)} GHz, high moisture (PWAT #{:erlang.float_to_binary(hrrr.pwat_mm, decimals: 1)} mm) causes significant water vapor absorption."
end
end
defp band_summary(_, _), do: nil
defp build_details(hrrr, soundings, elevation_profile, _band_mhz) do
details = []
details =
if hrrr && is_number(hrrr.min_refractivity_gradient) do
grad = round(hrrr.min_refractivity_gradient)
label =
cond do
grad < -157 -> "ducting"
grad < -100 -> "super-refractive"
grad < -79 -> "enhanced"
true -> "normal"
end
details ++ ["Refractivity gradient: #{grad} N-units/km (#{label})."]
else
details
end
details =
if hrrr && is_number(hrrr.hpbl_m) do
hpbl = round(hrrr.hpbl_m)
note = if hpbl < 300, do: " — shallow boundary layer favors ducting", else: ""
details ++ ["Boundary layer depth: #{hpbl} m#{note}."]
else
details
end
ducting_soundings = if is_list(soundings), do: Enum.filter(soundings, & &1.ducting_detected), else: []
details =
if ducting_soundings == [] do
details
else
names = Enum.map_join(ducting_soundings, ", ", fn s -> s.station.name || s.station.station_code end)
details ++ ["Ducting detected by soundings at: #{names}."]
end
details =
if elevation_profile do
ducts = elevation_profile.ducts || []
likely = Enum.find(ducts, & &1[:likely])
if likely do
details ++ ["Most likely propagation duct: #{duct_description(likely)}."]
else
details
end
else
details
end
details
end
# Factor note helpers
defp humidity_note(abs_h, band_config) do
h = Float.round(abs_h, 1)
effect = if band_config.humidity_effect == :beneficial, do: "beneficial", else: "harmful"
"#{h} g/m³ (#{effect} at this band)"
end
defp time_note(ts, lon) do
solar_hour = ts.hour + ts.minute / 60 + (lon || 0) / 15
solar_hour = rem_float(solar_hour + 24, 24)
h = round(solar_hour)
label =
cond do
h >= 4 and h < 8 -> "dawn"
h >= 8 and h < 12 -> "morning"
h >= 12 and h < 17 -> "afternoon"
h >= 17 and h < 21 -> "evening"
true -> "night"
end
"Solar hour ~#{h} (#{label})"
end
defp td_note(temp_f, dewpoint_f) when is_number(temp_f) and is_number(dewpoint_f) do
depression = Float.round(temp_f - dewpoint_f, 1)
label =
cond do
depression < 3 -> "near saturation"
depression < 8 -> "moist"
depression < 15 -> "moderate"
true -> "dry"
end
"T-Td = #{depression}°F (#{label})"
end
defp td_note(_, _), do: ""
defp refractivity_note(nil), do: ""
defp refractivity_note(grad) do
g = round(grad)
label =
cond do
g < -157 -> "ducting"
g < -100 -> "super-refractive"
g < -79 -> "enhanced"
true -> "normal"
end
"dN/dh = #{g} (#{label})"
end
defp sky_note(nil), do: ""
defp sky_note(pct), do: "#{round(pct)}% cloud cover"
defp season_note(month, band_config) do
month_names = ~w(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec)
name = Enum.at(month_names, month - 1)
effect = if band_config.humidity_effect == :beneficial, do: "summer peak", else: "winter peak"
"#{name} (#{effect} band)"
end
defp wind_note(nil), do: ""
defp wind_note(kts) do
k = Float.round(kts, 1)
label =
cond do
k < 5 -> "calm"
k < 15 -> "light"
k < 25 -> "moderate"
true -> "strong"
end
"#{k} kts (#{label})"
end
defp rain_note(rate) when rate > 0, do: "#{Float.round(rate, 1)} mm/hr"
defp rain_note(_), do: "None"
defp pwat_note(nil, _), do: ""
defp pwat_note(mm, band_config) do
effect = if band_config.humidity_effect == :beneficial, do: "refractivity aid", else: "absorption"
"#{Float.round(mm, 1)} mm (#{effect})"
end
defp pressure_note(nil), do: ""
defp pressure_note(mb) do
label =
cond do
mb < 1005 -> "low"
mb < 1015 -> "moderate"
true -> "high"
end
"#{Float.round(mb, 1)} mb (#{label})"
end
defp propagation_tier_label(score) do
cond do
score >= 80 -> "EXCELLENT"
score >= 65 -> "GOOD"
score >= 50 -> "MARGINAL"
score >= 33 -> "POOR"
true -> "NEGLIGIBLE"
end
end
defp propagation_tier_class(score) do
cond do
score >= 80 -> "badge badge-sm badge-success"
score >= 65 -> "badge badge-sm badge-info"
score >= 50 -> "badge badge-sm badge-warning"
score >= 33 -> "badge badge-sm badge-error"
true -> "badge badge-sm badge-ghost"
end
end
end