Removed n_live_tup > 0 filter that hid tables when pg_stats reports 0 rows during VACUUM. Added dead tuple column with warning highlight to monitor vacuum progress. Also includes Styler reformatting and unused dep cleanup.
1670 lines
57 KiB
Elixir
1670 lines
57 KiB
Elixir
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
|
||
|
||
@impl true
|
||
def mount(%{"id" => id}, _session, socket) do
|
||
contact = id |> Radio.get_contact!() |> Radio.ensure_positions!()
|
||
|
||
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 = maybe_enqueue_solar(contact)
|
||
hrrr_path = Weather.hrrr_profiles_for_path(contact)
|
||
hrrr = List.first(hrrr_path)
|
||
{hrrr, contact} = maybe_enqueue_hrrr(hrrr, contact)
|
||
terrain = Terrain.get_terrain_profile(contact.id)
|
||
contact = maybe_enqueue_terrain(terrain, contact)
|
||
contact = maybe_enqueue_weather(weather, contact)
|
||
elevation_profile = compute_elevation_profile(contact, hrrr_path, weather.soundings)
|
||
propagation_analysis = build_propagation_analysis(contact, hrrr, 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,
|
||
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()
|
||
)}
|
||
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, _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)
|
||
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,
|
||
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, 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)
|
||
hrrr = List.first(hrrr_path)
|
||
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,
|
||
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_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 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}>
|
||
<.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 && @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)} · {@contact.mode} · {format_dist(
|
||
@elevation_profile.dist_km
|
||
)} · {@contact.grid1 || "—"} → {@contact.grid2 || "—"}
|
||
</div>
|
||
<table class="text-sm font-mono mb-2 mx-auto">
|
||
<tr>
|
||
<td class="pr-6">{@contact.station1} → {@contact.station2}</td>
|
||
<td class="pr-6">Az: {format_bearing(@elevation_profile.fwd_az)}</td>
|
||
<td>El: {@elevation_profile.fwd_el}°</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="pr-6">{@contact.station2} → {@contact.station1}</td>
|
||
<td class="pr-6">Az: {format_bearing(@elevation_profile.rev_az)}</td>
|
||
<td>El: {@elevation_profile.rev_el}°</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">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 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
|
||
|
||
# ── 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, 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, %{pos1: nil}, _band_config), do: {nil, [], nil}
|
||
|
||
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
|