- Replace deprecated xref:exclude with elixirc_options in vendor/oban_pro - Remove unused require Logger from 8 files - Pin bitstring size variables with ^ in simple_packing, wgrib2, complex_packing, section, nexrad_client, mqtt, and radar worker - Remove dead defp clauses (format/1 nil, depression/2 nil) - Rename Buildings.Parser type record/0 -> building_record/0 to avoid overriding built-in type - Remove redundant catch-all in candidate_detail.ex - Simplify contact_live/show.ex conditional based on type narrowing - Fix DateTime.from_iso8601 return pattern in vendor/oban_web - Upgrade phoenix_live_view to 1.1.31 - Drop --warnings-as-errors from precommit alias; known false positives from @after_verify-generated code in Elixir 1.20.0-rc.6 + PLV 1.1.x - Add credo --strict to precommit as replacement static-analysis gate
2537 lines
89 KiB
Elixir
2537 lines
89 KiB
Elixir
defmodule MicrowavepropWeb.ContactLive.Show do
|
||
@moduledoc "Per-QSO detail at `/contacts/:id`: weather, HRRR, radar, terrain, charts."
|
||
use MicrowavepropWeb, :live_view
|
||
|
||
import MicrowavepropWeb.Components.SkewTChart
|
||
|
||
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.Weather.HrrrNativeProfile
|
||
alias Microwaveprop.Weather.HrrrPointEnqueuer
|
||
alias Microwaveprop.Weather.NarrClient
|
||
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
|
||
alias Microwaveprop.Workers.NarrFetchWorker
|
||
alias Microwaveprop.Workers.SolarIndexWorker
|
||
alias Microwaveprop.Workers.TerrainProfileWorker
|
||
alias MicrowavepropWeb.ContactLive.Mechanism
|
||
|
||
require Logger
|
||
|
||
@earth_radius_m 6_371_000.0
|
||
# T-Mobile CGNAT range — only enqueue enrichment jobs from this subnet
|
||
@enqueue_subnet {172, 56, 0, 0}
|
||
@enqueue_mask 13
|
||
|
||
@band_options BandConfig.band_options()
|
||
@mode_options ~w(CW SSB FM FT8 FT4 Q65)
|
||
|
||
@impl true
|
||
def mount(%{"id" => id}, session, socket) do
|
||
contact = Radio.get_contact!(id)
|
||
|
||
if !Radio.can_view?(contact, socket.assigns[:current_scope]) do
|
||
raise Ecto.NoResultsError, queryable: Microwaveprop.Radio.Contact
|
||
end
|
||
|
||
contact = Radio.ensure_positions!(contact)
|
||
can_enqueue = internal_network?(session)
|
||
|
||
socket =
|
||
assign(socket,
|
||
page_title: "#{contact.station1} / #{contact.station2}",
|
||
contact: contact,
|
||
can_enqueue: can_enqueue,
|
||
surface_observations: [],
|
||
soundings: [],
|
||
sounding_search_radius_km: nil,
|
||
sounding_search_pending?: false,
|
||
solar: nil,
|
||
hrrr: nil,
|
||
hrrr_path: [],
|
||
native_profile: nil,
|
||
narr: nil,
|
||
narr_path: [],
|
||
iemre: nil,
|
||
radar: nil,
|
||
mechanism: nil,
|
||
terrain: nil,
|
||
elevation_profile: nil,
|
||
propagation_analysis: nil,
|
||
data_sources: nil,
|
||
terrain_expanded: false,
|
||
hrrr_profile_expanded: true,
|
||
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(),
|
||
hydration_pending: MapSet.new(),
|
||
editing: false,
|
||
edit_form: nil,
|
||
pending_edit: load_pending_edit(contact, socket),
|
||
band_options: @band_options,
|
||
mode_options: @mode_options
|
||
)
|
||
|
||
socket =
|
||
if connected?(socket) do
|
||
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:#{contact.id}")
|
||
|
||
kickoff_hydration(socket, contact)
|
||
else
|
||
socket
|
||
end
|
||
|
||
{:ok, socket}
|
||
end
|
||
|
||
# Each enrichment source loads in its own task so users see sections appear
|
||
# independently instead of waiting for the slowest one. handle_async/3
|
||
# clauses below update the matching slot and re-run the derived analysis.
|
||
defp kickoff_hydration(socket, contact) do
|
||
socket
|
||
|> assign(:hydration_pending, pending_slots(contact))
|
||
|> start_async(:weather, fn -> load_weather(contact) end)
|
||
|> start_async(:solar, fn -> load_solar(contact) end)
|
||
|> start_async(:hrrr_path, fn -> Weather.hrrr_profiles_for_path(contact) end)
|
||
|> start_async(:narr_path, fn -> Weather.narr_profiles_for_path(contact) end)
|
||
|> start_async(:native_profile, fn -> load_native_profile(contact) end)
|
||
|> start_async(:terrain, fn -> Terrain.get_terrain_profile(contact.id) end)
|
||
|> start_async(:iemre, fn -> load_iemre(contact) end)
|
||
|> start_async(:radar, fn -> load_radar(contact) end)
|
||
end
|
||
|
||
# A slot shows its loading spinner only while the corresponding enrichment
|
||
# column on the contact says the work is genuinely in flight. For contacts
|
||
# whose status is :complete (or :pending/:unavailable/:failed), the async
|
||
# query just returns whatever is cached without any intermediate flash.
|
||
defp pending_slots(contact) do
|
||
in_flight = [:queued, :processing]
|
||
slots = []
|
||
slots = if contact.weather_status in in_flight, do: [:weather | slots], else: slots
|
||
|
||
slots =
|
||
if contact.hrrr_status in in_flight,
|
||
do: [:hrrr_path, :narr_path, :native_profile | slots],
|
||
else: slots
|
||
|
||
slots = if contact.terrain_status in in_flight, do: [:terrain | slots], else: slots
|
||
slots = if contact.iemre_status in in_flight, do: [:iemre | slots], else: slots
|
||
MapSet.new(slots)
|
||
end
|
||
|
||
defp mark_hydrated(socket, slot) do
|
||
assign(socket, :hydration_pending, MapSet.delete(socket.assigns.hydration_pending, slot))
|
||
end
|
||
|
||
defp atmospheric_loading?(pending) do
|
||
MapSet.member?(pending, :hrrr_path) or
|
||
MapSet.member?(pending, :narr_path) or
|
||
MapSet.member?(pending, :native_profile)
|
||
end
|
||
|
||
defp load_radar(contact) do
|
||
import Ecto.Query
|
||
|
||
alias Microwaveprop.Propagation.RainScatterClassifier
|
||
alias Microwaveprop.Radio.ContactCommonVolumeRadar
|
||
alias Microwaveprop.Repo
|
||
|
||
radar = Repo.get_by(ContactCommonVolumeRadar, contact_id: contact.id)
|
||
hrrr_path = Weather.hrrr_profiles_for_path(contact)
|
||
duct_either? = Enum.any?(hrrr_path, &(&1 && &1.ducting_detected))
|
||
|
||
band_mhz = Decimal.to_integer(Decimal.round(contact.band, 0))
|
||
|
||
distance_km =
|
||
case contact.distance_km do
|
||
nil -> 0.0
|
||
d -> Decimal.to_float(d)
|
||
end
|
||
|
||
mechanism =
|
||
RainScatterClassifier.classify(%{
|
||
band_mhz: band_mhz,
|
||
distance_km: distance_km,
|
||
radar:
|
||
if(radar,
|
||
do: %{
|
||
max_dbz: radar.max_dbz,
|
||
heavy_rain_pixel_count: radar.heavy_rain_pixel_count,
|
||
coverage_pct: radar.coverage_pct
|
||
}
|
||
),
|
||
duct_either_endpoint: duct_either?
|
||
})
|
||
|
||
%{row: radar, mechanism: mechanism}
|
||
end
|
||
|
||
# The native HRRR profile gives the skew-T a full-atmosphere trace (50
|
||
# hybrid levels up to ~19 km) for historical hours we've backfilled.
|
||
# Falls back silently to nil when no native profile is on disk; the
|
||
# chart then uses the pressure-level profile instead.
|
||
defp load_native_profile(%{pos1: %{"lat" => lat, "lon" => lon}, qso_timestamp: ts}) do
|
||
Weather.find_nearest_native_profile(lat, lon, ts)
|
||
end
|
||
|
||
defp load_native_profile(_), do: nil
|
||
|
||
defp load_pending_edit(contact, socket) do
|
||
case socket.assigns do
|
||
%{current_scope: %{user: %{id: user_id}}} ->
|
||
Radio.pending_edit_for_user(contact.id, user_id)
|
||
|
||
_ ->
|
||
nil
|
||
end
|
||
end
|
||
|
||
defp current_user(%{current_scope: %{user: %{} = user}}), do: user
|
||
defp current_user(_), do: nil
|
||
|
||
defp admin?(%{current_scope: %{user: %{is_admin: true}}}), do: true
|
||
defp admin?(_), do: false
|
||
|
||
@impl true
|
||
def handle_event("sort", %{"field" => field, "table" => "obs"}, socket) do
|
||
{sort_by, sort_order} =
|
||
toggle_sort(socket.assigns.obs_sort_by, socket.assigns.obs_sort_order, field)
|
||
|
||
sorted = sort_observations(socket.assigns.surface_observations, sort_by, sort_order)
|
||
|
||
{:noreply,
|
||
assign(socket,
|
||
surface_observations: sorted,
|
||
obs_sort_by: sort_by,
|
||
obs_sort_order: sort_order
|
||
)}
|
||
end
|
||
|
||
def handle_event("toggle_hrrr_profile", _params, socket) do
|
||
{:noreply, assign(socket, hrrr_profile_expanded: !socket.assigns.hrrr_profile_expanded)}
|
||
end
|
||
|
||
def handle_event("toggle_terrain", _params, socket) do
|
||
{:noreply, assign(socket, terrain_expanded: !socket.assigns.terrain_expanded)}
|
||
end
|
||
|
||
def handle_event("delete_contact", _params, socket) do
|
||
if admin?(socket.assigns) do
|
||
Radio.delete_contact!(socket.assigns.contact)
|
||
|
||
{:noreply,
|
||
socket
|
||
|> put_flash(:info, "Contact deleted.")
|
||
|> push_navigate(to: ~p"/contacts")}
|
||
else
|
||
{:noreply, put_flash(socket, :error, "Admins only.")}
|
||
end
|
||
end
|
||
|
||
def handle_event("toggle_flag", _params, socket) do
|
||
if admin?(socket.assigns) do
|
||
flagger = socket.assigns.current_scope.user
|
||
contact = Radio.toggle_flagged_invalid!(socket.assigns.contact, flagger)
|
||
contact = Microwaveprop.Repo.preload(contact, :flagged_by_user)
|
||
{:noreply, assign(socket, contact: contact)}
|
||
else
|
||
{:noreply, put_flash(socket, :error, "Admins only.")}
|
||
end
|
||
end
|
||
|
||
def handle_event("toggle_profile", %{"id" => id}, socket) do
|
||
expanded =
|
||
if MapSet.member?(socket.assigns.expanded_soundings, id) do
|
||
MapSet.delete(socket.assigns.expanded_soundings, id)
|
||
else
|
||
MapSet.put(socket.assigns.expanded_soundings, id)
|
||
end
|
||
|
||
{:noreply, assign(socket, expanded_soundings: expanded)}
|
||
end
|
||
|
||
def handle_event("sort", %{"field" => field, "table" => "soundings"}, socket) do
|
||
{sort_by, sort_order} =
|
||
toggle_sort(socket.assigns.sounding_sort_by, socket.assigns.sounding_sort_order, field)
|
||
|
||
sorted = sort_soundings(socket.assigns.soundings, sort_by, sort_order)
|
||
|
||
{:noreply,
|
||
assign(socket,
|
||
soundings: sorted,
|
||
sounding_sort_by: sort_by,
|
||
sounding_sort_order: sort_order
|
||
)}
|
||
end
|
||
|
||
def handle_event("toggle_edit", _params, socket) do
|
||
if socket.assigns.editing do
|
||
{:noreply, assign(socket, editing: false, edit_form: nil)}
|
||
else
|
||
contact = socket.assigns.contact
|
||
|
||
form_data = %{
|
||
"station1" => contact.station1,
|
||
"station2" => contact.station2,
|
||
"grid1" => contact.grid1,
|
||
"grid2" => contact.grid2,
|
||
"band" => if(contact.band, do: Decimal.to_string(contact.band), else: ""),
|
||
"mode" => contact.mode,
|
||
"qso_timestamp" =>
|
||
if(contact.qso_timestamp, do: Calendar.strftime(contact.qso_timestamp, "%Y-%m-%d %H:%M"), else: ""),
|
||
"height1_ft" => contact.height1_ft,
|
||
"height2_ft" => contact.height2_ft,
|
||
"private" => contact.private
|
||
}
|
||
|
||
{:noreply, assign(socket, editing: true, edit_form: to_form(form_data, as: "edit"))}
|
||
end
|
||
end
|
||
|
||
def handle_event("validate_edit", %{"edit" => _params}, socket) do
|
||
{:noreply, socket}
|
||
end
|
||
|
||
def handle_event("submit_edit", %{"edit" => params}, socket) do
|
||
user = current_user(socket.assigns)
|
||
|
||
if is_nil(user) do
|
||
{:noreply, put_flash(socket, :error, "You must be logged in to edit.")}
|
||
else
|
||
{:noreply, submit_edit(socket, user, params)}
|
||
end
|
||
end
|
||
|
||
defp submit_edit(socket, user, params) do
|
||
contact = socket.assigns.contact
|
||
|
||
cond do
|
||
admin?(socket.assigns) ->
|
||
apply_admin_edit(socket, contact, params)
|
||
|
||
Radio.owner?(contact, user) ->
|
||
apply_owner_edit(socket, contact, user, params)
|
||
|
||
true ->
|
||
submit_user_edit(socket, contact, user, params)
|
||
end
|
||
end
|
||
|
||
defp apply_admin_edit(socket, contact, params) do
|
||
proposed = Radio.diff_against_contact(contact, Radio.normalize_proposed(params))
|
||
|
||
if proposed == %{} do
|
||
put_flash(socket, :error, "No changes detected.")
|
||
else
|
||
_ = Radio.apply_edit_to_contact(contact, proposed)
|
||
updated = Radio.get_contact!(contact.id)
|
||
|
||
socket
|
||
|> assign(contact: updated, editing: false, edit_form: nil)
|
||
|> put_flash(:info, "Contact updated.")
|
||
end
|
||
end
|
||
|
||
defp apply_owner_edit(socket, contact, user, params) do
|
||
case Radio.apply_owner_edit(contact, user, params) do
|
||
{:ok, _updated} ->
|
||
updated = Radio.get_contact!(contact.id)
|
||
|
||
socket
|
||
|> assign(contact: updated, editing: false, edit_form: nil)
|
||
|> put_flash(:info, "Contact updated.")
|
||
|
||
{:error, :no_changes} ->
|
||
put_flash(socket, :error, "No changes detected.")
|
||
|
||
{:error, :not_owner} ->
|
||
# Shouldn't happen because we checked owner? before dispatching,
|
||
# but guard anyway so the LiveView doesn't blow up on stale state.
|
||
put_flash(socket, :error, "You can only edit contacts you submitted.")
|
||
end
|
||
end
|
||
|
||
defp submit_user_edit(socket, contact, user, params) do
|
||
case Radio.create_contact_edit(contact, user, params) do
|
||
{:ok, _edit} ->
|
||
socket
|
||
|> assign(editing: false, edit_form: nil, pending_edit: Radio.pending_edit_for_user(contact.id, user.id))
|
||
|> put_flash(:info, "Edit submitted for review.")
|
||
|
||
{:error, changeset} ->
|
||
messages =
|
||
changeset
|
||
|> Ecto.Changeset.traverse_errors(fn {msg, _opts} -> msg end)
|
||
|> Enum.flat_map(fn {_field, msgs} -> msgs end)
|
||
|> Enum.join(", ")
|
||
|
||
put_flash(socket, :error, "Could not submit edit: #{messages}")
|
||
end
|
||
end
|
||
|
||
@impl true
|
||
def handle_async(:weather, {:ok, weather}, socket) do
|
||
contact = socket.assigns.contact
|
||
|
||
contact =
|
||
if socket.assigns.can_enqueue, do: maybe_enqueue_weather(weather, contact), else: contact
|
||
|
||
socket =
|
||
socket
|
||
|> assign(
|
||
contact: contact,
|
||
surface_observations: weather.surface_observations,
|
||
soundings: weather.soundings,
|
||
sounding_search_radius_km: Map.get(weather, :sounding_search_radius_km),
|
||
sounding_search_pending?: false
|
||
)
|
||
|> mark_hydrated(:weather)
|
||
|> maybe_kick_off_sounding_fetch(weather)
|
||
|> refresh_computed()
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
def handle_async(:solar, {:ok, solar}, socket) do
|
||
solar =
|
||
if socket.assigns.can_enqueue and is_nil(solar) do
|
||
_ = enqueue_missing_solar(socket.assigns.contact)
|
||
nil
|
||
else
|
||
solar
|
||
end
|
||
|
||
{:noreply, socket |> assign(:solar, solar) |> mark_hydrated(:solar)}
|
||
end
|
||
|
||
def handle_async(:hrrr_path, {:ok, hrrr_path}, socket) do
|
||
contact = socket.assigns.contact
|
||
hrrr = List.first(hrrr_path)
|
||
|
||
{hrrr, contact} =
|
||
if socket.assigns.can_enqueue, do: maybe_enqueue_hrrr(hrrr, contact), else: {hrrr, contact}
|
||
|
||
socket =
|
||
socket
|
||
|> assign(contact: contact, hrrr: hrrr, hrrr_path: hrrr_path)
|
||
|> mark_hydrated(:hrrr_path)
|
||
|> refresh_computed()
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
def handle_async(:narr_path, {:ok, narr_path}, socket) do
|
||
narr = List.first(narr_path)
|
||
contact = socket.assigns.contact
|
||
|
||
contact =
|
||
if socket.assigns.can_enqueue, do: maybe_enqueue_narr(narr, contact), else: contact
|
||
|
||
{:noreply,
|
||
socket
|
||
|> assign(contact: contact, narr: narr, narr_path: narr_path)
|
||
|> mark_hydrated(:narr_path)}
|
||
end
|
||
|
||
def handle_async(:terrain, {:ok, terrain}, socket) do
|
||
contact = socket.assigns.contact
|
||
|
||
contact =
|
||
if socket.assigns.can_enqueue, do: maybe_enqueue_terrain(terrain, contact), else: contact
|
||
|
||
socket =
|
||
socket
|
||
|> assign(contact: contact, terrain: terrain)
|
||
|> mark_hydrated(:terrain)
|
||
|> refresh_computed()
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
def handle_async(:iemre, {:ok, iemre}, socket) do
|
||
{:noreply, socket |> assign(:iemre, iemre) |> mark_hydrated(:iemre)}
|
||
end
|
||
|
||
def handle_async(:radar, {:ok, %{row: row, mechanism: mechanism}}, socket) do
|
||
{:noreply, socket |> assign(radar: row, mechanism: mechanism) |> mark_hydrated(:radar)}
|
||
end
|
||
|
||
def handle_async(:native_profile, {:ok, native_profile}, socket) do
|
||
{:noreply, socket |> assign(:native_profile, native_profile) |> mark_hydrated(:native_profile)}
|
||
end
|
||
|
||
def handle_async(slot, {:exit, reason}, socket)
|
||
when slot in [:weather, :solar, :hrrr_path, :narr_path, :native_profile, :terrain, :iemre, :radar] do
|
||
Logger.warning("contact hydrate task #{slot} exited: #{inspect(reason)}")
|
||
{:noreply, mark_hydrated(socket, slot)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info({:sounding_fetched, %{contact_id: contact_id}}, socket) do
|
||
if socket.assigns.contact.id == contact_id do
|
||
contact = socket.assigns.contact
|
||
weather = load_weather(contact)
|
||
|
||
socket =
|
||
socket
|
||
|> assign(
|
||
soundings: weather.soundings,
|
||
sounding_search_radius_km: weather.sounding_search_radius_km,
|
||
sounding_search_pending?: weather.soundings == []
|
||
)
|
||
|> refresh_computed()
|
||
|
||
{:noreply, socket}
|
||
else
|
||
{:noreply, socket}
|
||
end
|
||
end
|
||
|
||
def handle_info({:terrain_ready, _contact_id}, socket) do
|
||
contact = %{socket.assigns.contact | terrain_status: :complete}
|
||
terrain = Terrain.get_terrain_profile(contact.id)
|
||
soundings = socket.assigns.soundings
|
||
hrrr_path = Weather.hrrr_profiles_for_path(contact)
|
||
elevation_profile = compute_elevation_profile(contact, hrrr_path, soundings)
|
||
propagation_analysis = build_propagation_analysis(contact, hrrr_path, terrain, elevation_profile, soundings)
|
||
|
||
{:noreply,
|
||
assign(socket,
|
||
contact: contact,
|
||
terrain: terrain,
|
||
elevation_profile: elevation_profile,
|
||
propagation_analysis: propagation_analysis
|
||
)}
|
||
end
|
||
|
||
# Soundings are sparse, so even the 1000 km widening search can come up
|
||
# empty for a given contact when no RAOB data has been ingested yet.
|
||
# When that happens, enqueue fetches for stations at the widest radius
|
||
# that has any; the WeatherFetchWorker broadcasts back via
|
||
# `contact_enrichment:<contact_id>` so this LiveView rehydrates when
|
||
# each sounding lands.
|
||
defp maybe_kick_off_sounding_fetch(socket, weather) do
|
||
contact = socket.assigns.contact
|
||
|
||
cond do
|
||
not socket.assigns.can_enqueue ->
|
||
socket
|
||
|
||
not Map.get(weather, :sounding_search_exhausted?, false) ->
|
||
socket
|
||
|
||
is_nil(contact.pos1) ->
|
||
socket
|
||
|
||
true ->
|
||
case ContactWeatherEnqueueWorker.enqueue_raob_fetch_with_widening(contact) do
|
||
{:ok, %{jobs_enqueued: jobs, radius_km: radius}} when jobs > 0 ->
|
||
assign(socket,
|
||
sounding_search_pending?: true,
|
||
sounding_search_radius_km: radius
|
||
)
|
||
|
||
_ ->
|
||
socket
|
||
end
|
||
end
|
||
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
|
||
|
||
# Rebuild derived analysis from whatever's currently in assigns. Called from
|
||
# each handle_async clause that updates a source slot, so sections that
|
||
# depend on multiple sources (elevation_profile, propagation_analysis,
|
||
# data_sources) refresh as each upstream source arrives.
|
||
defp refresh_computed(socket) do
|
||
%{
|
||
contact: contact,
|
||
hrrr: hrrr,
|
||
hrrr_path: hrrr_path,
|
||
terrain: terrain,
|
||
surface_observations: surface_observations,
|
||
soundings: soundings
|
||
} = socket.assigns
|
||
|
||
elevation_profile = compute_elevation_profile(contact, hrrr_path, soundings)
|
||
|
||
propagation_analysis =
|
||
build_propagation_analysis(contact, hrrr_path, terrain, elevation_profile, soundings)
|
||
|
||
data_sources =
|
||
build_data_sources(
|
||
hrrr,
|
||
hrrr_path,
|
||
terrain,
|
||
%{surface_observations: surface_observations, soundings: soundings},
|
||
elevation_profile
|
||
)
|
||
|
||
mechanism = upgrade_mechanism(socket.assigns[:mechanism], elevation_profile)
|
||
|
||
assign(socket,
|
||
elevation_profile: elevation_profile,
|
||
propagation_analysis: propagation_analysis,
|
||
data_sources: data_sources,
|
||
mechanism: mechanism
|
||
)
|
||
end
|
||
|
||
# The radar-only RainScatterClassifier sees ducting only when HRRR's
|
||
# pressure-level M-profile already flagged it. Sounding and gradient-
|
||
# inferred ducts surface in elevation_profile.ducts via the analysis
|
||
# pipeline — promote those into :tropo_duct so the Mechanism panel
|
||
# agrees with the Propagation Analysis narrative.
|
||
defp upgrade_mechanism(nil, _profile), do: nil
|
||
defp upgrade_mechanism(:tropo_duct, _profile), do: :tropo_duct
|
||
defp upgrade_mechanism(:likely_rainscatter, _profile), do: :likely_rainscatter
|
||
|
||
defp upgrade_mechanism(mechanism, %{ducts: [_ | _] = ducts}) do
|
||
if Enum.any?(ducts, &Map.get(&1, :likely)), do: :tropo_duct, else: mechanism
|
||
end
|
||
|
||
defp upgrade_mechanism(mechanism, _profile), do: mechanism
|
||
|
||
defp load_iemre(contact) do
|
||
case Weather.iemre_for_contact(contact) do
|
||
%{hourly: hourly} = obs when is_list(hourly) and hourly != [] ->
|
||
qso_hour = contact.qso_timestamp.hour
|
||
nearest = Enum.min_by(hourly, fn h -> abs((h["utc_hour"] || 0) - qso_hour) end)
|
||
%{observation: obs, nearest_hour: nearest}
|
||
|
||
_ ->
|
||
nil
|
||
end
|
||
end
|
||
|
||
defp load_weather(contact) do
|
||
case extract_contact_coords(contact) do
|
||
{:ok, lat1, lon1, lat2, lon2} ->
|
||
fetch_weather_for_path(contact, lat1, lon1, lat2, lon2)
|
||
|
||
:error ->
|
||
%{
|
||
surface_observations: [],
|
||
soundings: [],
|
||
sounding_search_exhausted?: false,
|
||
sounding_search_radius_km: nil
|
||
}
|
||
end
|
||
end
|
||
|
||
defp extract_contact_coords(contact) do
|
||
lat1 = contact.pos1 && contact.pos1["lat"]
|
||
lon1 = contact.pos1 && contact.pos1["lon"]
|
||
|
||
if lat1 && lon1 do
|
||
lat2 = contact.pos2 && contact.pos2["lat"]
|
||
lon2 = contact.pos2 && contact.pos2["lon"]
|
||
{:ok, lat1, lon1, lat2, lon2}
|
||
else
|
||
:error
|
||
end
|
||
end
|
||
|
||
defp fetch_weather_for_path(contact, lat1, lon1, lat2, lon2) do
|
||
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
|
||
)
|
||
|
||
# The surface-observation radius tracks the path geometry, but
|
||
# soundings are sparse (~120/day nationwide) so we widen out to
|
||
# 1000 km before giving up and letting the fetch-on-demand path
|
||
# (handle_async(:soundings_widen)) backfill from IEM.
|
||
sounding_search =
|
||
Weather.soundings_with_widening_radius(%{
|
||
lat: mid_lat,
|
||
lon: mid_lon,
|
||
timestamp: contact.qso_timestamp
|
||
})
|
||
|
||
%{
|
||
surface_observations: closest_observations(weather.surface_observations, mid_lat, mid_lon, 5),
|
||
soundings: sounding_search.soundings,
|
||
sounding_search_exhausted?: sounding_search.exhausted,
|
||
sounding_search_radius_km: sounding_search.radius_km
|
||
}
|
||
end
|
||
|
||
defp closest_observations(observations, mid_lat, mid_lon, limit) do
|
||
observations
|
||
|> Enum.sort_by(fn obs ->
|
||
abs(obs.station.lat - mid_lat) + abs(obs.station.lon - mid_lon)
|
||
end)
|
||
|> Enum.take(limit)
|
||
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"]
|
||
|
||
# Always re-enqueue when there's no profile on this page render.
|
||
# The `hrrr_status == :queued` guard used to short-circuit here, but
|
||
# contacts can end up stuck in :queued without a live task row
|
||
# (HrrrPointEnqueuer swallowing an exception, Rust worker marking a
|
||
# task :done with a points array that never contained this contact's
|
||
# point, or a later retention prune removing the row). Re-entering
|
||
# the upsert is idempotent — the ON CONFLICT clause in
|
||
# HrrrPointEnqueuer unions points, resets :done/:failed rows back
|
||
# to :queued, and no-ops if the point + valid_time already match.
|
||
if lat && lon do
|
||
valid_time = HrrrClient.nearest_hrrr_hour(contact.qso_timestamp)
|
||
{rlat, rlon} = Weather.round_to_hrrr_grid(lat, lon)
|
||
|
||
# Phase 3 Stream C: route retries through hrrr_fetch_tasks so the
|
||
# Rust hrrr-point-worker picks them up alongside backfill work.
|
||
{:ok, _} = HrrrPointEnqueuer.enqueue(%{valid_time => [{rlat, rlon}]})
|
||
|
||
_ =
|
||
if contact.hrrr_status != :queued do
|
||
Radio.set_enrichment_status!([contact.id], :hrrr_status, :queued)
|
||
end
|
||
|
||
{nil, %{contact | hrrr_status: :queued}}
|
||
else
|
||
{nil, contact}
|
||
end
|
||
end
|
||
|
||
# NARR is the fallback for contacts where HRRR is unavailable (pre-2014 or
|
||
# missing from the archive). Only enqueue when HRRR is known-unavailable AND
|
||
# we don't already have a NARR profile.
|
||
defp maybe_enqueue_narr(narr, contact) when not is_nil(narr), do: contact
|
||
|
||
defp maybe_enqueue_narr(nil, %{hrrr_status: :unavailable} = contact) do
|
||
lat = contact.pos1 && contact.pos1["lat"]
|
||
lon = contact.pos1 && contact.pos1["lon"]
|
||
valid_time = NarrClient.snap_to_analysis_hour(contact.qso_timestamp)
|
||
|
||
_ =
|
||
if lat && lon && NarrClient.in_coverage?(valid_time) do
|
||
%{
|
||
"lat" => lat,
|
||
"lon" => lon,
|
||
"valid_time" => DateTime.to_iso8601(valid_time)
|
||
}
|
||
|> NarrFetchWorker.new()
|
||
|> Oban.insert()
|
||
end
|
||
|
||
contact
|
||
end
|
||
|
||
defp maybe_enqueue_narr(_nil, contact), do: contact
|
||
|
||
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
|
||
Microwaveprop.Cache.fetch_or_store({__MODULE__, :queue_counts}, 5_000, fn ->
|
||
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)
|
||
end
|
||
|
||
defp internal_network?(session) do
|
||
case session["remote_ip"] do
|
||
nil ->
|
||
false
|
||
|
||
ip_str ->
|
||
case :inet.parse_address(String.to_charlist(ip_str)) do
|
||
{:ok, {a, b, c, d}} ->
|
||
ip_int = Bitwise.bsl(a, 24) + Bitwise.bsl(b, 16) + Bitwise.bsl(c, 8) + d
|
||
{sa, sb, sc, sd} = @enqueue_subnet
|
||
subnet_int = Bitwise.bsl(sa, 24) + Bitwise.bsl(sb, 16) + Bitwise.bsl(sc, 8) + sd
|
||
mask = Bitwise.bsl(0xFFFFFFFF, 32 - @enqueue_mask)
|
||
Bitwise.band(ip_int, mask) == Bitwise.band(subnet_int, mask)
|
||
|
||
_ ->
|
||
false
|
||
end
|
||
end
|
||
end
|
||
|
||
defp load_solar(contact) do
|
||
contact.qso_timestamp
|
||
|> DateTime.to_date()
|
||
|> Weather.get_solar_index()
|
||
end
|
||
|
||
defp enqueue_missing_solar(contact) do
|
||
date = DateTime.to_date(contact.qso_timestamp)
|
||
Oban.insert(SolarIndexWorker.new(%{"date" => Date.to_iso8601(date)}))
|
||
end
|
||
|
||
@impl true
|
||
def render(assigns) do
|
||
~H"""
|
||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||
<.header>
|
||
<span class={[@contact.flagged_invalid && "line-through opacity-50"]}>
|
||
{@contact.station1} / {@contact.station2}
|
||
</span>
|
||
<:subtitle>
|
||
{format_band(@contact.band)}
|
||
<%= if @contact.mode do %>
|
||
· {@contact.mode}
|
||
<% end %>
|
||
· {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 whitespace-nowrap"
|
||
title={flag_tooltip(@contact)}
|
||
>
|
||
Flagged invalid{flag_attribution(@contact)}
|
||
</span>
|
||
<% end %>
|
||
<%= if @contact.private do %>
|
||
<span
|
||
class="badge badge-warning badge-sm ml-2"
|
||
title="Only visible to you and administrators"
|
||
>
|
||
<.icon name="hero-lock-closed" class="w-3 h-3 mr-1" /> Private
|
||
</span>
|
||
<% end %>
|
||
</:subtitle>
|
||
<:actions>
|
||
<%= if current_user(assigns) do %>
|
||
<button
|
||
phx-click="toggle_edit"
|
||
class={["btn btn-sm", if(@editing, do: "btn-ghost", else: "btn-outline")]}
|
||
>
|
||
<.icon
|
||
name={if @editing, do: "hero-x-mark", else: "hero-pencil-square"}
|
||
class="w-4 h-4"
|
||
/>
|
||
{cond do
|
||
@editing -> "Cancel"
|
||
admin?(assigns) or Radio.owner?(@contact, current_user(assigns)) -> "Edit"
|
||
true -> "Suggest Edit"
|
||
end}
|
||
</button>
|
||
<% end %>
|
||
<button
|
||
:if={admin?(assigns)}
|
||
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>
|
||
<button
|
||
:if={admin?(assigns)}
|
||
phx-click="delete_contact"
|
||
data-confirm={"Permanently delete this contact (#{@contact.station1} / #{@contact.station2})? This cannot be undone."}
|
||
class="btn btn-sm btn-error btn-outline"
|
||
>
|
||
<.icon name="hero-trash" class="w-4 h-4" /> Delete
|
||
</button>
|
||
<.link navigate={path_calc_url(@contact)} class="btn btn-sm btn-outline">
|
||
<.icon name="hero-calculator" class="w-4 h-4" /> Open in Path Calculator
|
||
</.link>
|
||
<.link navigate={~p"/contacts"} class="btn btn-sm btn-ghost">
|
||
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to Contacts
|
||
</.link>
|
||
</:actions>
|
||
</.header>
|
||
|
||
<%= if @pending_edit && !@editing && !admin?(assigns) && !Radio.owner?(@contact, current_user(assigns)) do %>
|
||
<div class="alert alert-info text-sm mb-4">
|
||
<.icon name="hero-clock" class="w-4 h-4 inline" />
|
||
You have a pending edit for this contact awaiting admin review.
|
||
</div>
|
||
<% end %>
|
||
|
||
<%= if @editing do %>
|
||
<% direct_edit = admin?(assigns) or Radio.owner?(@contact, current_user(assigns)) %>
|
||
<div class="bg-base-200 rounded-box p-4 mb-4">
|
||
<h3 class="font-semibold mb-3">
|
||
{if direct_edit, do: "Edit Contact", else: "Suggest Edit"}
|
||
</h3>
|
||
<p class="text-sm opacity-70 mb-4">
|
||
{if direct_edit,
|
||
do: "Change any fields below. Changes will be submitted for review.",
|
||
else: "Change any fields below. Only fields you modify will be submitted for review."}
|
||
</p>
|
||
<.form
|
||
for={@edit_form}
|
||
id="edit-form"
|
||
phx-change="validate_edit"
|
||
phx-submit="submit_edit"
|
||
class="space-y-4"
|
||
>
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<.input field={@edit_form[:station1]} type="text" label="Station 1" />
|
||
<.input field={@edit_form[:grid1]} type="text" label="Grid 1" />
|
||
<.input
|
||
field={@edit_form[:height1_ft]}
|
||
type="number"
|
||
label="Height 1 (ft AGL)"
|
||
min="0"
|
||
max="1000"
|
||
placeholder="Optional"
|
||
/>
|
||
<.input field={@edit_form[:station2]} type="text" label="Station 2" />
|
||
<.input field={@edit_form[:grid2]} type="text" label="Grid 2" />
|
||
<.input
|
||
field={@edit_form[:height2_ft]}
|
||
type="number"
|
||
label="Height 2 (ft AGL)"
|
||
min="0"
|
||
max="1000"
|
||
placeholder="Optional"
|
||
/>
|
||
</div>
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<.input field={@edit_form[:band]} type="select" label="Band" options={@band_options} />
|
||
<.input field={@edit_form[:mode]} type="select" label="Mode" options={@mode_options} />
|
||
<.input
|
||
field={@edit_form[:qso_timestamp]}
|
||
type="text"
|
||
label="Timestamp (UTC, 24h)"
|
||
placeholder="YYYY-MM-DD HH:MM"
|
||
pattern="\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?Z?"
|
||
/>
|
||
</div>
|
||
<div class="mt-2">
|
||
<.input
|
||
field={@edit_form[:private]}
|
||
type="checkbox"
|
||
label="Private — only visible to me and administrators"
|
||
/>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<.button type="submit" class="btn btn-primary btn-sm">
|
||
<.icon name="hero-paper-airplane" class="w-4 h-4" />
|
||
{if direct_edit, do: "Save Changes", else: "Submit for Review"}
|
||
</.button>
|
||
<button type="button" phx-click="toggle_edit" class="btn btn-ghost btn-sm">
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</.form>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%= if @contact.pos1 && @contact.pos2 do %>
|
||
<p class="text-xs text-base-content/50 italic mb-1">
|
||
Locations approximate, in the center of the grid squares.
|
||
<%= if current_user(assigns) do %>
|
||
Have more detailed position information?
|
||
<button
|
||
type="button"
|
||
phx-click="toggle_edit"
|
||
class="link link-hover not-italic text-base-content/70"
|
||
>
|
||
Suggest an edit
|
||
</button>
|
||
<% else %>
|
||
Have more detailed position information?
|
||
<.link
|
||
navigate={~p"/users/log-in"}
|
||
class="link link-hover not-italic text-base-content/70"
|
||
>
|
||
Log in to suggest an edit
|
||
</.link>
|
||
<% end %>
|
||
</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(@contact.band)} · {@contact.mode} · {format_dist(
|
||
@elevation_profile.dist_km
|
||
)} · <.grid_link grid={@contact.grid1} /> →
|
||
<.grid_link grid={@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="alert alert-error alert-soft py-1 mb-2 text-sm">
|
||
Obstructed at {format_dist(@elevation_profile.first_obstruction_km)}
|
||
</div>
|
||
<% end %>
|
||
<%= if @elevation_profile.verdict == "CLEAR" do %>
|
||
<div class="alert alert-success alert-soft py-1 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)}
|
||
data-station1={@contact.station1}
|
||
data-station2={@contact.station2}
|
||
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">Propagation Mechanism</h2>
|
||
<div class="mb-6 border border-base-300 rounded-lg p-4 text-sm">
|
||
<div class="flex items-center gap-3 flex-wrap">
|
||
<span class={["badge badge-sm", Mechanism.badge_class(@mechanism)]}>
|
||
{Mechanism.label(@mechanism)}
|
||
</span>
|
||
<span class="opacity-70">{Mechanism.explainer(@mechanism)}</span>
|
||
</div>
|
||
<%= if @radar do %>
|
||
<div class="mt-3 flex flex-wrap gap-4 text-xs opacity-80">
|
||
<span>
|
||
Common volume: {format_number(@radar.common_volume_km2, 0)} km²
|
||
</span>
|
||
<%= if @radar.max_dbz do %>
|
||
<span>Max reflectivity: {format_number(@radar.max_dbz, 1)} dBZ</span>
|
||
<% end %>
|
||
<span>Heavy-rain pixels: {@radar.heavy_rain_pixel_count}</span>
|
||
<%= if @radar.coverage_pct do %>
|
||
<span>Radar coverage: {format_number(@radar.coverage_pct, 0)}%</span>
|
||
<% end %>
|
||
<span class="opacity-60">
|
||
Frame: {Calendar.strftime(@radar.observed_at, "%Y-%m-%d %H:%M UTC")}
|
||
</span>
|
||
</div>
|
||
<% end %>
|
||
</div>
|
||
|
||
<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">
|
||
<%= cond do %>
|
||
<% MapSet.member?(@hydration_pending, :terrain) -> %>
|
||
<span class="flex items-center gap-2">
|
||
<span class="loading loading-spinner loading-sm"></span> Loading terrain profile…
|
||
</span>
|
||
<% @contact.terrain_status == :queued -> %>
|
||
<span class="flex items-center gap-2">
|
||
<span class="loading loading-spinner loading-sm"></span>
|
||
Computing terrain profile {queue_info(@queue_counts, "terrain")}
|
||
</span>
|
||
<% true -> %>
|
||
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">
|
||
<%= cond do %>
|
||
<% MapSet.member?(@hydration_pending, :weather) -> %>
|
||
<span class="flex items-center gap-2">
|
||
<span class="loading loading-spinner loading-sm"></span> Loading soundings…
|
||
</span>
|
||
<% @sounding_search_pending? -> %>
|
||
<span class="flex items-center gap-2">
|
||
<span class="loading loading-spinner loading-sm"></span>
|
||
Fetching soundings from IEM (search radius {@sounding_search_radius_km} km)…
|
||
</span>
|
||
<% @contact.weather_status == :queued -> %>
|
||
<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>
|
||
<% true -> %>
|
||
No soundings found within 1000 km.
|
||
<% 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">Atmospheric Profile</h2>
|
||
<% profile = @hrrr || @narr %>
|
||
<% profile_source =
|
||
cond do
|
||
@hrrr -> "HRRR (3 km)"
|
||
@narr -> "NARR (32 km)"
|
||
true -> nil
|
||
end %>
|
||
<% skew_t_levels =
|
||
cond do
|
||
@native_profile ->
|
||
HrrrNativeProfile.to_skew_t_profile(@native_profile)
|
||
|
||
profile && profile.profile ->
|
||
profile.profile
|
||
|
||
true ->
|
||
[]
|
||
end %>
|
||
<% skew_t_source =
|
||
cond do
|
||
@native_profile -> "HRRR native (50 hybrid levels)"
|
||
true -> profile_source
|
||
end %>
|
||
<%= if profile 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 flex-wrap items-center gap-x-4 gap-y-2 text-sm min-w-0">
|
||
<span class="badge badge-outline badge-sm whitespace-nowrap">{profile_source}</span>
|
||
<span class="font-semibold whitespace-nowrap">
|
||
{format_number(profile.lat)}°, {format_number(profile.lon)}°
|
||
</span>
|
||
<span class="whitespace-nowrap">
|
||
{Calendar.strftime(profile.valid_time, "%H:%M UTC")}
|
||
</span>
|
||
<span class="whitespace-nowrap">HPBL: {format_number(profile.hpbl_m)} m</span>
|
||
<span class="whitespace-nowrap">PWAT: {format_number(profile.pwat_mm)} mm</span>
|
||
<span class="whitespace-nowrap">
|
||
N: {format_number(profile.surface_refractivity)}
|
||
</span>
|
||
<span class="whitespace-nowrap">
|
||
dN/dh: {format_number(profile.min_refractivity_gradient)}
|
||
</span>
|
||
<%= if profile.ducting_detected do %>
|
||
<span class="badge badge-warning badge-sm whitespace-nowrap">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(profile.surface_temp_c)}°C</div>
|
||
<div>Surface Dewpoint: {format_number(profile.surface_dewpoint_c)}°C</div>
|
||
<div>Surface Pressure: {format_number(profile.surface_pressure_mb)} mb</div>
|
||
<div>
|
||
<%= if run_time = Map.get(profile, :run_time) do %>
|
||
Run Time: {Calendar.strftime(run_time, "%Y-%m-%d %H:%M UTC")}
|
||
<% else %>
|
||
Source: {profile_source} reanalysis
|
||
<% end %>
|
||
</div>
|
||
</div>
|
||
<%= if skew_t_levels != [] do %>
|
||
<%= if @native_profile do %>
|
||
<div class="text-xs text-base-content/70 mb-2">
|
||
Skew-T source: <span class="font-semibold">{skew_t_source}</span>
|
||
</div>
|
||
<% end %>
|
||
<.skew_t_chart profile={skew_t_levels} />
|
||
<div class="overflow-x-auto mt-4">
|
||
<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 <- skew_t_levels 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">
|
||
<%= cond do %>
|
||
<% atmospheric_loading?(@hydration_pending) -> %>
|
||
<span class="flex items-center gap-2">
|
||
<span class="loading loading-spinner loading-sm"></span> Loading atmospheric profile…
|
||
</span>
|
||
<% @contact.hrrr_status == :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>
|
||
<% @contact.hrrr_status == :unavailable -> %>
|
||
<span class="flex items-center gap-2">
|
||
<span class="loading loading-spinner loading-sm"></span>
|
||
HRRR unavailable — fetching NARR reanalysis {queue_info(@queue_counts, "narr")}
|
||
</span>
|
||
<% true -> %>
|
||
No atmospheric profile available.
|
||
<% end %>
|
||
</div>
|
||
<% end %>
|
||
|
||
<div class="divider" />
|
||
|
||
<h2 class="text-base font-semibold mb-2">IEMRE Gridded Reanalysis</h2>
|
||
<%= if @iemre do %>
|
||
<% h = @iemre.nearest_hour %>
|
||
<% obs = @iemre.observation %>
|
||
<div class="text-xs opacity-60 mb-2">
|
||
Grid point: {format_number(obs.lat)}°, {format_number(obs.lon)}° — {obs.date} hour {h[
|
||
"utc_hour"
|
||
] || "—"} UTC
|
||
</div>
|
||
<div class="flex flex-wrap gap-x-6 gap-y-1 text-sm">
|
||
<div><span class="font-semibold">Temp:</span> {format_number(h["air_temp_f"])}°F</div>
|
||
<div><span class="font-semibold">Dewpoint:</span> {format_number(h["dew_point_f"])}°F</div>
|
||
<div><span class="font-semibold">Sky Cover:</span> {format_number(h["skyc_%"])}%</div>
|
||
<div>
|
||
<span class="font-semibold">Wind:</span>
|
||
{format_number(wind_speed_from_components(h["uwnd_mps"], h["vwnd_mps"]))} kts
|
||
</div>
|
||
<div>
|
||
<span class="font-semibold">Precip:</span> {format_number(h["hourly_precip_in"])}"
|
||
</div>
|
||
<div :if={h["soil4t_f"]}>
|
||
<span class="font-semibold">Soil Temp:</span> {format_number(h["soil4t_f"])}°F
|
||
</div>
|
||
</div>
|
||
<% else %>
|
||
<div class="text-sm text-base-content/50 italic">
|
||
<%= case @contact.iemre_status do %>
|
||
<% :queued -> %>
|
||
<span class="flex items-center gap-2">
|
||
<span class="loading loading-spinner loading-sm"></span> Fetching IEMRE data
|
||
</span>
|
||
<% :unavailable -> %>
|
||
IEMRE data unavailable for this contact.
|
||
<% _ -> %>
|
||
No IEMRE data available.
|
||
<% end %>
|
||
</div>
|
||
<% end %>
|
||
|
||
<div class="divider" />
|
||
|
||
<h2 class="text-base font-semibold mb-2">Surface Observations</h2>
|
||
<%= if @surface_observations == [] do %>
|
||
<div class="text-sm text-base-content/50 italic">
|
||
<%= if @contact.weather_status == :queued do %>
|
||
<span class="flex items-center gap-2">
|
||
<span class="loading loading-spinner loading-sm"></span>
|
||
Fetching surface observations from nearby stations {queue_info(@queue_counts, "weather")}...
|
||
</span>
|
||
<% else %>
|
||
No surface observations found nearby.
|
||
<% end %>
|
||
</div>
|
||
<% else %>
|
||
<div class="overflow-x-auto">
|
||
<table class="table table-xs table-zebra">
|
||
<thead>
|
||
<tr>
|
||
<th>Station</th>
|
||
<th>Time</th>
|
||
<th>Temp</th>
|
||
<th>Dewpt</th>
|
||
<th>RH%</th>
|
||
<th>Wind</th>
|
||
<th>Pres (mb)</th>
|
||
<th>Sky</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<%= for obs <- @surface_observations do %>
|
||
<tr>
|
||
<td>{obs.station.name || obs.station.station_code}</td>
|
||
<td>{Calendar.strftime(obs.observed_at, "%H:%M")}</td>
|
||
<td>{obs.temp_f || "—"}</td>
|
||
<td>{obs.dewpoint_f || "—"}</td>
|
||
<td>{format_number(obs.relative_humidity)}</td>
|
||
<td>{format_wind(obs)}</td>
|
||
<td>{obs.sea_level_pressure_mb || "—"}</td>
|
||
<td>{obs.sky_condition || "—"}</td>
|
||
</tr>
|
||
<% end %>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%= if @data_sources do %>
|
||
<div class="divider" />
|
||
|
||
<h2 class="text-base font-semibold mb-2">Data Sources</h2>
|
||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 text-xs">
|
||
<div class="card card-border card-sm bg-base-200">
|
||
<div class="card-body 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>
|
||
|
||
<div class="card card-border card-sm bg-base-200">
|
||
<div class="card-body 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>
|
||
|
||
<div class="card card-border card-sm bg-base-200">
|
||
<div class="card-body 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>
|
||
|
||
<div class="card card-border card-sm bg-base-200">
|
||
<div class="card-body 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>
|
||
|
||
<div class="card card-border card-sm bg-base-200">
|
||
<div class="card-body 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>
|
||
|
||
<div class="card card-border card-sm bg-base-200">
|
||
<div class="card-body 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>
|
||
</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_number(nil, _), do: "—"
|
||
|
||
defp format_number(n, decimals) when is_float(n) and is_integer(decimals) do
|
||
:erlang.float_to_binary(n, decimals: decimals)
|
||
end
|
||
|
||
defp format_number(n, _), do: to_string(n)
|
||
|
||
# mechanism_label/1, mechanism_badge_class/1, mechanism_explainer/1
|
||
# live in MicrowavepropWeb.ContactLive.Mechanism (pure helpers, no
|
||
# LiveView coupling). Call via Mechanism.label/1 etc. in render.
|
||
|
||
defp wind_speed_from_components(u, v) when is_number(u) and is_number(v) do
|
||
mps = :math.sqrt(u * u + v * v)
|
||
Float.round(mps * 1.944, 1)
|
||
end
|
||
|
||
defp wind_speed_from_components(_, _), do: nil
|
||
|
||
defp format_dist(km), do: Microwaveprop.Format.distance_km(km)
|
||
|
||
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(nil), do: "—"
|
||
|
||
defp format_band(band) do
|
||
mhz = Decimal.to_float(band)
|
||
|
||
if mhz < 10_000 do
|
||
if mhz == Float.round(mhz, 0) do
|
||
"#{trunc(mhz)} MHz"
|
||
else
|
||
"#{:erlang.float_to_binary(mhz, decimals: 1)} MHz"
|
||
end
|
||
else
|
||
ghz = mhz / 1000
|
||
|
||
if ghz == Float.round(ghz, 0) do
|
||
"#{trunc(ghz)} GHz"
|
||
else
|
||
"#{:erlang.float_to_binary(ghz, decimals: 1)} GHz"
|
||
end
|
||
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"],
|
||
%{"lat" => lat2} <- contact.pos2,
|
||
lon2 when is_number(lon2) <- contact.pos2["lon"],
|
||
{: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)
|
||
# Default to 10 ft (3.048 m) AGL when the operator didn't record an
|
||
# antenna height — better than pretending the antenna is on the dirt.
|
||
default_ht_m = 3.048
|
||
ant_ht_a_m = ft_to_m(contact.height1_ft) || default_ht_m
|
||
ant_ht_b_m = ft_to_m(contact.height2_ft) || default_ht_m
|
||
analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz, ant_ht_a: ant_ht_a_m, ant_ht_b: ant_ht_b_m)
|
||
|
||
fwd_az = initial_bearing(lat1, lon1, lat2, lon2)
|
||
rev_az = initial_bearing(lat2, lon2, lat1, lon1)
|
||
|
||
tx_elev = hd(profile).elev + ant_ht_a_m
|
||
rx_elev = List.last(profile).elev + ant_ht_b_m
|
||
fwd_el = elevation_angle(tx_elev, rx_elev, dist_km * 1000)
|
||
rev_el = elevation_angle(rx_elev, tx_elev, dist_km * 1000)
|
||
|
||
first_obs =
|
||
case Enum.find(analysis.points, & &1.obstructed) do
|
||
nil -> nil
|
||
p -> p.dist_km
|
||
end
|
||
|
||
ducts =
|
||
hrrr_path
|
||
|> extract_ducts(soundings, tx_elev)
|
||
|> merge_nearby_ducts()
|
||
|> Enum.sort_by(& &1.strength, :desc)
|
||
|> Enum.take(3)
|
||
|> mark_likely_duct(tx_elev, rx_elev)
|
||
|
||
%{
|
||
points:
|
||
Enum.map(analysis.points, fn p ->
|
||
%{elev: p.elev, beam: p.beam, r1: p.r1, dist_km: p.dist_km}
|
||
end),
|
||
freq_mhz: freq_ghz * 1000,
|
||
dist_km: dist_km,
|
||
k_factor: analysis.k_factor,
|
||
fwd_az: fwd_az,
|
||
rev_az: rev_az,
|
||
fwd_el: fwd_el,
|
||
rev_el: rev_el,
|
||
verdict: analysis.verdict,
|
||
first_obstruction_km: first_obs,
|
||
ducts: ducts
|
||
}
|
||
else
|
||
_ -> nil
|
||
end
|
||
end
|
||
|
||
# Extract duct layers from best available source across all HRRR path profiles:
|
||
# 1. HRRR explicit ducts (M-profile detected) from any path point
|
||
# 2. Sounding explicit ducts (high vertical resolution, most reliable)
|
||
# 3. Inferred from strongest HRRR refractivity gradient along path
|
||
defp extract_ducts(hrrr_path, soundings, surface_elev) do
|
||
hrrr_ducts = extract_hrrr_ducts(hrrr_path, surface_elev)
|
||
|
||
cond do
|
||
hrrr_ducts != [] ->
|
||
hrrr_ducts
|
||
|
||
(sounding_ducts = extract_sounding_ducts(soundings, surface_elev)) != [] ->
|
||
sounding_ducts
|
||
|
||
true ->
|
||
# 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)
|
||
end
|
||
end
|
||
|
||
defp extract_hrrr_ducts(hrrr_path, surface_elev) when is_list(hrrr_path) do
|
||
hrrr_path
|
||
|> Enum.flat_map(&duct_characteristics_to_maps(&1.duct_characteristics, surface_elev, "hrrr"))
|
||
|> Enum.uniq_by(fn d -> {round(d.base_m_msl), round(d.top_m_msl)} end)
|
||
end
|
||
|
||
defp duct_characteristics_to_maps(ducts, surface_elev, source) when is_list(ducts) and ducts != [] do
|
||
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: source
|
||
}
|
||
end)
|
||
end
|
||
|
||
defp duct_characteristics_to_maps(_ducts, _surface_elev, _source), do: []
|
||
|
||
defp extract_sounding_ducts(soundings, surface_elev) when is_list(soundings) do
|
||
soundings
|
||
|> Enum.filter(& &1.ducting_detected)
|
||
|> Enum.flat_map(&duct_characteristics_to_maps(&1.duct_characteristics, surface_elev, "sounding"))
|
||
|> 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 ft_to_m(nil), do: nil
|
||
defp ft_to_m(ft) when is_integer(ft), do: ft * 0.3048
|
||
|
||
defp haversine_km(lat1, lon1, lat2, lon2) do
|
||
dlat = deg_to_rad(lat2 - lat1)
|
||
dlon = deg_to_rad(lon2 - lon1)
|
||
rlat1 = deg_to_rad(lat1)
|
||
rlat2 = deg_to_rad(lat2)
|
||
|
||
a =
|
||
:math.sin(dlat / 2) ** 2 +
|
||
:math.cos(rlat1) * :math.cos(rlat2) * :math.sin(dlon / 2) ** 2
|
||
|
||
2 * 6371.0 * :math.asin(:math.sqrt(a))
|
||
end
|
||
|
||
defp initial_bearing(lat1, lon1, lat2, lon2) do
|
||
rlat1 = deg_to_rad(lat1)
|
||
rlat2 = deg_to_rad(lat2)
|
||
dlon = deg_to_rad(lon2 - lon1)
|
||
|
||
y = :math.sin(dlon) * :math.cos(rlat2)
|
||
|
||
x =
|
||
:math.cos(rlat1) * :math.sin(rlat2) -
|
||
:math.sin(rlat1) * :math.cos(rlat2) * :math.cos(dlon)
|
||
|
||
bearing = y |> :math.atan2(x) |> rad_to_deg()
|
||
Float.round(rem_float(bearing + 360, 360), 1)
|
||
end
|
||
|
||
defp elevation_angle(h_tx, h_rx, dist_m) do
|
||
k_r = 4 / 3 * @earth_radius_m
|
||
angle = :math.atan2(h_rx - h_tx, dist_m) - dist_m / (2 * k_r)
|
||
Float.round(rad_to_deg(angle), 2)
|
||
end
|
||
|
||
defp deg_to_rad(deg), do: deg * :math.pi() / 180
|
||
defp rad_to_deg(rad), do: rad * 180 / :math.pi()
|
||
defp rem_float(a, b), do: a - Float.floor(a / b) * b
|
||
|
||
# ── Data sources summary ────────────────────────────────────────
|
||
|
||
defp build_data_sources(hrrr, hrrr_path, terrain, weather, elevation_profile) do
|
||
%{
|
||
hrrr: build_hrrr_source(hrrr, hrrr_path),
|
||
elevation: build_elevation_source(elevation_profile),
|
||
terrain: build_terrain_source(terrain),
|
||
obs_count: length(weather.surface_observations),
|
||
sounding_count: length(weather.soundings)
|
||
}
|
||
end
|
||
|
||
defp build_hrrr_source(nil, _), do: nil
|
||
|
||
defp build_hrrr_source(hrrr, hrrr_path) do
|
||
levels = if hrrr.profile, do: length(hrrr.profile), else: 0
|
||
|
||
%{
|
||
profile_count: length(hrrr_path),
|
||
levels: levels,
|
||
valid_time: Calendar.strftime(hrrr.valid_time, "%Y-%m-%d %H:%M UTC"),
|
||
lat: :erlang.float_to_binary(hrrr.lat / 1, decimals: 2),
|
||
lon: :erlang.float_to_binary(hrrr.lon / 1, decimals: 2)
|
||
}
|
||
end
|
||
|
||
defp build_elevation_source(nil), do: nil
|
||
|
||
defp build_elevation_source(ep) do
|
||
%{
|
||
samples: length(ep.points),
|
||
dist: format_dist(ep.dist_km),
|
||
source: "SRTM 1-arcsecond",
|
||
duct_count: length(ep.ducts)
|
||
}
|
||
end
|
||
|
||
defp build_terrain_source(nil), do: nil
|
||
|
||
defp build_terrain_source(terrain) do
|
||
%{
|
||
samples: terrain.sample_count,
|
||
verdict: terrain.verdict,
|
||
max_elev: :erlang.float_to_binary((terrain.max_elevation_m || 0) / 1, decimals: 0),
|
||
diffraction: :erlang.float_to_binary((terrain.diffraction_db || 0) / 1, decimals: 1)
|
||
}
|
||
end
|
||
|
||
# ── Propagation analysis ──────────────────────────────────────────
|
||
|
||
defp build_propagation_analysis(contact, hrrr_path, terrain, elevation_profile, soundings) do
|
||
band_mhz = if contact.band, do: Decimal.to_integer(contact.band), else: 10_000
|
||
band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000)
|
||
dist_km = contact.distance_km && Decimal.to_float(contact.distance_km)
|
||
hrrr = List.first(hrrr_path)
|
||
|
||
{factors, factor_rows, composite} = compute_factors(hrrr_path, contact, band_config)
|
||
|
||
summary =
|
||
build_summary(terrain, elevation_profile, hrrr, soundings, dist_km, band_mhz, contact)
|
||
|
||
details = build_details(hrrr, hrrr_path, soundings, elevation_profile, band_mhz, contact)
|
||
|
||
%{
|
||
summary: summary,
|
||
details: details,
|
||
factors: factors,
|
||
factor_rows: factor_rows,
|
||
composite_score: composite
|
||
}
|
||
end
|
||
|
||
defp compute_factors([], _contact, _band_config), do: {nil, [], nil}
|
||
defp compute_factors(_hrrr_path, %{pos1: nil}, _band_config), do: {nil, [], nil}
|
||
|
||
defp compute_factors(hrrr_path, contact, band_config) do
|
||
conditions = Scorer.path_integrated_conditions(hrrr_path, contact)
|
||
|
||
if is_nil(conditions) do
|
||
{nil, [], nil}
|
||
else
|
||
result = Scorer.composite_score(conditions, band_config)
|
||
weights = BandConfig.weights(band_config)
|
||
|
||
factor_rows =
|
||
[
|
||
{:humidity, "Humidity", humidity_note(conditions.abs_humidity, band_config)},
|
||
{:time_of_day, "Time of Day", time_note(contact.qso_timestamp, conditions.longitude)},
|
||
{:td_depression, "T-Td Depression", td_note(conditions.temp_f, conditions.dewpoint_f)},
|
||
{:refractivity, "Refractivity", refractivity_note(conditions.min_refractivity_gradient)},
|
||
{:sky, "Sky Cover", sky_note(conditions.sky_cover_pct)},
|
||
{:season, "Season", season_note(contact.qso_timestamp.month, band_config)},
|
||
{:wind, "Wind", wind_note(conditions.wind_speed_kts)},
|
||
{:rain, "Rain", rain_note(conditions.rain_rate_mmhr)},
|
||
{:pwat, "PWAT", pwat_note(conditions.pwat_mm, band_config)},
|
||
{:pressure, "Pressure", pressure_note(conditions.pressure_mb)}
|
||
]
|
||
|> Enum.map(fn {key, name, note} ->
|
||
score = result.factors[key]
|
||
weight = weights[key]
|
||
|
||
%{
|
||
name: name,
|
||
score: score,
|
||
weight: round(weight * 100),
|
||
contribution: Float.round(score * weight, 1),
|
||
note: note
|
||
}
|
||
end)
|
||
|> Enum.sort_by(& &1.contribution, :desc)
|
||
|
||
{result.factors, factor_rows, result.score}
|
||
end
|
||
end
|
||
|
||
defp build_summary(terrain, elevation_profile, hrrr, soundings, dist_km, band_mhz, contact) do
|
||
terrain_status = terrain_summary(terrain, elevation_profile, contact)
|
||
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, _contact), do: "No terrain data available."
|
||
|
||
defp terrain_summary(terrain, elevation_profile, contact) do
|
||
verdict = (elevation_profile && elevation_profile.verdict) || (terrain && terrain.verdict)
|
||
describe_verdict(verdict, elevation_profile, contact)
|
||
end
|
||
|
||
defp describe_verdict("CLEAR", _, _), do: "Line of sight is clear between stations."
|
||
defp describe_verdict("FRESNEL_MINOR", _, _), do: "Line of sight is clear but with minor Fresnel zone encroachment."
|
||
defp describe_verdict("FRESNEL_PARTIAL", _, _), do: "Line of sight has partial Fresnel zone obstruction."
|
||
|
||
defp describe_verdict("BLOCKED", elevation_profile, contact) do
|
||
obs_dist = elevation_profile && elevation_profile.first_obstruction_km
|
||
blocked_message(obs_dist, contact)
|
||
end
|
||
|
||
defp describe_verdict(_, _, _), do: nil
|
||
|
||
defp blocked_message(nil, _contact), do: "Path is terrain-obstructed."
|
||
|
||
defp blocked_message(obs_dist, contact) do
|
||
origin = contact && contact.station1
|
||
origin_label = if origin, do: origin, else: "station 1"
|
||
|
||
"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 #{origin_label}."
|
||
end
|
||
|
||
defp propagation_mechanism(terrain, elevation_profile, hrrr, soundings, dist_km) do
|
||
blocked? = terrain && terrain.verdict == "BLOCKED"
|
||
ducting? = has_ducting?(hrrr, soundings)
|
||
enhanced_refraction? = enhanced_refraction?(hrrr)
|
||
long_path? = dist_km && dist_km > 100
|
||
|
||
ducts = (elevation_profile && elevation_profile.ducts) || []
|
||
likely_duct = Enum.find(ducts, & &1[:likely])
|
||
|
||
props = %{
|
||
blocked?: blocked?,
|
||
ducting?: ducting?,
|
||
enhanced_refraction?: enhanced_refraction?,
|
||
long_path?: long_path?,
|
||
likely_duct: likely_duct,
|
||
terrain: terrain,
|
||
hrrr: hrrr
|
||
}
|
||
|
||
if blocked? do
|
||
blocked_mechanism(props)
|
||
else
|
||
clear_path_mechanism(props)
|
||
end
|
||
end
|
||
|
||
defp enhanced_refraction?(nil), do: false
|
||
|
||
defp enhanced_refraction?(hrrr) do
|
||
is_number(hrrr.min_refractivity_gradient) && hrrr.min_refractivity_gradient < -100
|
||
end
|
||
|
||
defp blocked_mechanism(%{ducting?: true, likely_duct: duct}) when not is_nil(duct) do
|
||
"Signal likely propagated via atmospheric ducting (#{duct_description(duct)}), bending over the terrain obstruction."
|
||
end
|
||
|
||
defp blocked_mechanism(%{ducting?: true}) do
|
||
"Ducting conditions detected — signal likely propagated through a tropospheric duct, bypassing the terrain obstruction."
|
||
end
|
||
|
||
defp blocked_mechanism(%{enhanced_refraction?: true, hrrr: hrrr}) do
|
||
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."
|
||
end
|
||
|
||
defp blocked_mechanism(%{terrain: terrain}) do
|
||
diff_db = terrain && terrain.diffraction_db
|
||
blocked_diffraction_message(diff_db)
|
||
end
|
||
|
||
defp blocked_diffraction_message(diff_db) when is_number(diff_db) and 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."
|
||
end
|
||
|
||
defp blocked_diffraction_message(_) do
|
||
"Path is obstructed. Contact likely enabled by tropospheric scatter or transient ducting conditions."
|
||
end
|
||
|
||
defp clear_path_mechanism(%{ducting?: true, long_path?: true, likely_duct: duct}) when not is_nil(duct) do
|
||
"Ducting conditions present (#{duct_description(duct)}) — likely extending range beyond normal line of sight."
|
||
end
|
||
|
||
defp clear_path_mechanism(%{ducting?: true, long_path?: true}) do
|
||
"Ducting conditions present — likely contributing to extended range."
|
||
end
|
||
|
||
defp clear_path_mechanism(%{enhanced_refraction?: true, long_path?: true}) do
|
||
"Enhanced refraction present — may have contributed to extended range."
|
||
end
|
||
|
||
defp clear_path_mechanism(_), do: nil
|
||
|
||
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, hrrr_path, soundings, elevation_profile, _band_mhz, contact) do
|
||
Enum.reject(
|
||
[
|
||
antenna_heights_detail(contact),
|
||
refractivity_detail(hrrr),
|
||
boundary_layer_detail(hrrr),
|
||
path_duct_count_detail(hrrr_path),
|
||
sounding_ducting_detail(soundings),
|
||
duct_layer_detail(elevation_profile)
|
||
],
|
||
&is_nil/1
|
||
)
|
||
end
|
||
|
||
defp antenna_heights_detail(%{height1_ft: h1, height2_ft: h2} = contact) when is_integer(h1) or is_integer(h2) do
|
||
s1 = contact.station1 || "Station 1"
|
||
s2 = contact.station2 || "Station 2"
|
||
|
||
parts =
|
||
Enum.reject(
|
||
[if(is_integer(h1), do: "#{s1} @ #{h1} ft AGL"), if(is_integer(h2), do: "#{s2} @ #{h2} ft AGL")],
|
||
&is_nil/1
|
||
)
|
||
|
||
"Antenna heights: #{Enum.join(parts, ", ")}."
|
||
end
|
||
|
||
defp antenna_heights_detail(_), do: nil
|
||
|
||
defp path_duct_count_detail(hrrr_path) when is_list(hrrr_path) and hrrr_path != [] do
|
||
total = length(hrrr_path)
|
||
ducting = Enum.count(hrrr_path, &(&1 && &1.ducting_detected))
|
||
|
||
if ducting > 0 do
|
||
"Tropo duct detected at #{ducting}/#{total} HRRR samples along the path."
|
||
end
|
||
end
|
||
|
||
defp path_duct_count_detail(_), do: nil
|
||
|
||
defp refractivity_detail(%{min_refractivity_gradient: grad}) when is_number(grad) do
|
||
g = round(grad)
|
||
label = refractivity_label(g)
|
||
"Refractivity gradient: #{g} N-units/km (#{label})."
|
||
end
|
||
|
||
defp refractivity_detail(_), do: nil
|
||
|
||
defp refractivity_label(g) when g < -157, do: "ducting"
|
||
defp refractivity_label(g) when g < -100, do: "super-refractive"
|
||
defp refractivity_label(g) when g < -79, do: "enhanced"
|
||
defp refractivity_label(_), do: "normal"
|
||
|
||
defp boundary_layer_detail(%{hpbl_m: hpbl}) when is_number(hpbl) do
|
||
h = round(hpbl)
|
||
note = if h < 300, do: " — shallow boundary layer favors ducting", else: ""
|
||
"Boundary layer depth: #{h} m#{note}."
|
||
end
|
||
|
||
defp boundary_layer_detail(_), do: nil
|
||
|
||
defp sounding_ducting_detail(soundings) when is_list(soundings) do
|
||
ducting = Enum.filter(soundings, & &1.ducting_detected)
|
||
|
||
if ducting == [] do
|
||
nil
|
||
else
|
||
names = Enum.map_join(ducting, ", ", fn s -> s.station.name || s.station.station_code end)
|
||
"Ducting detected by soundings at: #{names}."
|
||
end
|
||
end
|
||
|
||
defp sounding_ducting_detail(_), do: nil
|
||
|
||
defp duct_layer_detail(%{ducts: ducts}) when is_list(ducts) do
|
||
likely = Enum.find(ducts, & &1[:likely])
|
||
if likely, do: "Most likely propagation duct: #{duct_description(likely)}."
|
||
end
|
||
|
||
defp duct_layer_detail(_), do: nil
|
||
|
||
# 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 / 15
|
||
solar_hour = rem_float(solar_hour + 24, 24)
|
||
h = round(solar_hour)
|
||
"Solar hour ~#{h} (#{solar_period(h)})"
|
||
end
|
||
|
||
defp solar_period(h) when h >= 4 and h < 8, do: "dawn"
|
||
defp solar_period(h) when h >= 8 and h < 12, do: "morning"
|
||
defp solar_period(h) when h >= 12 and h < 17, do: "afternoon"
|
||
defp solar_period(h) when h >= 17 and h < 21, do: "evening"
|
||
defp solar_period(_), do: "night"
|
||
|
||
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 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 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
|
||
|
||
defp path_calc_url(contact) do
|
||
source = prefer_grid(contact.grid1, contact.station1)
|
||
dest = prefer_grid(contact.grid2, contact.station2)
|
||
|
||
params = maybe_put_band(%{"source" => source, "destination" => dest}, contact.band)
|
||
|
||
"/path?" <> URI.encode_query(params)
|
||
end
|
||
|
||
defp prefer_grid(grid, _fallback) when is_binary(grid) and grid != "", do: grid
|
||
defp prefer_grid(_grid, fallback), do: fallback || ""
|
||
|
||
defp maybe_put_band(params, nil), do: params
|
||
|
||
defp maybe_put_band(params, %Decimal{} = band) do
|
||
Map.put(params, "band", Decimal.to_string(band, :normal))
|
||
end
|
||
|
||
defp maybe_put_band(params, band) when is_integer(band), do: Map.put(params, "band", Integer.to_string(band))
|
||
defp maybe_put_band(params, band) when is_binary(band), do: Map.put(params, "band", band)
|
||
defp maybe_put_band(params, _), do: params
|
||
|
||
# " by W5XYZ" suffix when we know who flagged it; nothing
|
||
# otherwise (legacy flags from before the column was added have
|
||
# `flagged_by_user_id == nil`).
|
||
defp flag_attribution(%{flagged_by_user: %{callsign: callsign}}) when is_binary(callsign) do
|
||
" by " <> callsign
|
||
end
|
||
|
||
defp flag_attribution(_), do: ""
|
||
|
||
defp flag_tooltip(%{flagged_by_user: %{callsign: callsign}, flagged_at: %DateTime{} = at}) when is_binary(callsign) do
|
||
"Flagged by #{callsign} on #{Calendar.strftime(at, "%Y-%m-%d %H:%M UTC")}"
|
||
end
|
||
|
||
defp flag_tooltip(%{flagged_at: %DateTime{} = at}) do
|
||
"Flagged on #{Calendar.strftime(at, "%Y-%m-%d %H:%M UTC")}"
|
||
end
|
||
|
||
defp flag_tooltip(_), do: "Flagged invalid"
|
||
|
||
attr :grid, :string, default: nil
|
||
|
||
defp grid_link(assigns) do
|
||
~H"""
|
||
<%= if @grid && @grid != "" do %>
|
||
<a
|
||
href={"https://gridmap.org/#{@grid}"}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
class="link link-hover"
|
||
>
|
||
{@grid}
|
||
</a>
|
||
<% else %>
|
||
—
|
||
<% end %>
|
||
"""
|
||
end
|
||
end
|