prop/lib/microwaveprop_web/live/contact_live/show.ex
Graham McIntire d207863b15
Live-update contact detail page when enrichment completes
- HRRR and terrain workers broadcast via PubSub on completion
- Contact show page subscribes and recomputes elevation profile,
  propagation analysis, and HRRR data on receipt
- Page updates in-place without reload when background jobs finish
2026-04-02 08:17:43 -05:00

1368 lines
46 KiB
Elixir

defmodule MicrowavepropWeb.ContactLive.Show do
@moduledoc false
use MicrowavepropWeb, :live_view
require Logger
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.HrrrFetchWorker
alias Microwaveprop.Workers.TerrainProfileWorker
@earth_radius_m 6_371_000.0
@impl true
def mount(%{"id" => id}, _session, socket) do
contact = Radio.get_contact!(id)
if connected?(socket) do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:#{contact.id}")
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:hrrr")
end
weather = load_weather(contact)
solar = load_solar(contact)
hrrr_path = Weather.hrrr_profiles_for_path(contact)
hrrr = List.first(hrrr_path)
{hrrr, hrrr_status} = maybe_enqueue_hrrr(hrrr, contact)
terrain = Terrain.get_terrain_profile(contact.id)
maybe_enqueue_terrain(terrain, contact)
elevation_profile = compute_elevation_profile(contact, hrrr_path, weather.soundings)
propagation_analysis = build_propagation_analysis(contact, hrrr, terrain, elevation_profile, weather.soundings)
{:ok,
assign(socket,
page_title: "#{contact.station1} / #{contact.station2}",
contact: contact,
surface_observations: weather.surface_observations,
soundings: weather.soundings,
solar: solar,
hrrr: hrrr,
hrrr_status: hrrr_status,
terrain: terrain,
elevation_profile: elevation_profile,
propagation_analysis: propagation_analysis,
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",
expanded_soundings: MapSet.new()
)}
end
@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
@impl true
def handle_info({:terrain_ready, _qso_id}, socket) do
contact = socket.assigns.contact
terrain = Terrain.get_terrain_profile(contact.id)
soundings = socket.assigns.soundings
hrrr_path = Weather.hrrr_profiles_for_path(contact)
hrrr = List.first(hrrr_path)
elevation_profile = compute_elevation_profile(contact, hrrr_path, soundings)
propagation_analysis = build_propagation_analysis(contact, hrrr, terrain, elevation_profile, soundings)
{:noreply,
assign(socket,
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
soundings = socket.assigns.soundings
terrain = socket.assigns.terrain
elevation_profile = compute_elevation_profile(contact, hrrr_path, soundings)
propagation_analysis = build_propagation_analysis(contact, hrrr, terrain, elevation_profile, soundings)
{:noreply,
assign(socket,
hrrr: hrrr,
hrrr_status: :loaded,
elevation_profile: elevation_profile,
propagation_analysis: propagation_analysis
)}
else
{:noreply, socket}
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_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: :ok
defp maybe_enqueue_terrain(nil, contact) do
if contact.pos1 && contact.pos2 do
Oban.insert(TerrainProfileWorker.new(%{"qso_id" => contact.id}))
end
:ok
end
defp maybe_enqueue_hrrr(hrrr, _contact) when not is_nil(hrrr), do: {hrrr, :loaded}
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 do
valid_time = HrrrClient.nearest_hrrr_hour(contact.qso_timestamp)
{rlat, rlon} = Weather.round_to_hrrr_grid(lat, lon)
Oban.insert(
HrrrFetchWorker.new(%{
"lat" => rlat,
"lon" => rlon,
"valid_time" => DateTime.to_iso8601(valid_time)
})
)
{nil, :queued}
else
{nil, :unavailable}
end
end
defp load_solar(contact) do
contact.qso_timestamp
|> DateTime.to_date()
|> Weather.get_solar_index()
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.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>
<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 @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 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 %>
<p class="text-sm text-base-content/50 italic">No terrain profile available.</p>
<% end %>
<div class="divider" />
<h2 class="text-base font-semibold mb-2">Soundings</h2>
<%= if @soundings == [] do %>
<p class="text-sm text-base-content/50 italic">No soundings found nearby.</p>
<% 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 %>
<p class="text-sm text-base-content/50 italic">No solar data available for this date.</p>
<% 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 %>
<p class="text-sm text-base-content/50 italic">
<%= case @hrrr_status do %>
<% :queued -> %>
HRRR fetch queued — reload in a few minutes.
<% :unavailable -> %>
HRRR data unavailable for this contact time.
<% _ -> %>
No HRRR profile available.
<% end %>
</p>
<% end %>
<div class="divider" />
<h2 class="text-base font-semibold mb-2">Surface Observations</h2>
<%= if @surface_observations == [] do %>
<p class="text-sm text-base-content/50 italic">No surface observations found nearby.</p>
<% 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 %>
</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 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,
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
# ── Propagation analysis ──────────────────────────────────────────
defp build_propagation_analysis(contact, hrrr, 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)
{factors, factor_rows, composite} = compute_factors(hrrr, 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(nil, _contact, _band_config) do
{nil, [], nil}
end
defp compute_factors(hrrr, contact, band_config) do
lon = contact.pos1["lon"] || contact.pos1["lng"]
temp_c = hrrr.surface_temp_c
dewpoint_c = hrrr.surface_dewpoint_c
if is_nil(temp_c) or is_nil(dewpoint_c) do
{nil, [], nil}
else
temp_f = Scorer.c_to_f(temp_c)
dewpoint_f = Scorer.c_to_f(dewpoint_c)
conditions = %{
abs_humidity: Scorer.absolute_humidity(temp_c, dewpoint_c),
temp_f: temp_f,
dewpoint_f: dewpoint_f,
wind_speed_kts: nil,
sky_cover_pct: nil,
utc_hour: contact.qso_timestamp.hour,
utc_minute: contact.qso_timestamp.minute,
month: contact.qso_timestamp.month,
longitude: lon,
pressure_mb: hrrr.surface_pressure_mb,
prev_pressure_mb: nil,
rain_rate_mmhr: 0.0,
min_refractivity_gradient: hrrr.min_refractivity_gradient,
bl_depth_m: hrrr.hpbl_m,
pwat_mm: hrrr.pwat_mm
}
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, lon)},
{:td_depression, "T-Td Depression", td_note(temp_f, dewpoint_f)},
{:refractivity, "Refractivity", refractivity_note(hrrr.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(hrrr.pwat_mm, band_config)},
{:pressure, "Pressure", pressure_note(hrrr.surface_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