prop/lib/microwaveprop_web/live/contact_live/show.ex
Graham McIntire 079346a1b9
Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 4m38s
fix: resolve all 281 credo issues across source and test files
- F-level (228): replace length/1 with Enum.count_until/2 or pattern
  matching; convert Enum.flat_map+if to Enum.filter+Enum.map; fix
  identity case in narr_client
- W-level (46): normalize dual atom/string key access in weather_layers,
  beacon_measurements, surface, skewt_live, contact_live/show; add
  :data_provider and weather-map assigns to ignored_assigns in credo
  config (consumed by child components credo can't trace); remove
  weak is_list assertion; remove explicit assert_receive timeout
- R-level (6): replace 'This module provides...' moduledocs with
  meaningful descriptions
- Also fix 4 compile-connected xref issues by deferring
  BandConfig.band_options() from module attribute to runtime

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 09:04:21 -05:00

2170 lines
74 KiB
Elixir
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule MicrowavepropWeb.ContactLive.Show do
@moduledoc "Per-QSO detail at `/contacts/:id`: weather, HRRR, radar, terrain, charts."
use MicrowavepropWeb, :live_view
import Ecto.Query
import MicrowavepropWeb.Components.SkewTChart
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.PathAnalysis
alias Microwaveprop.Propagation.RainScatterClassifier
alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Radio
alias Microwaveprop.Radio.ContactCommonVolumeRadar
alias Microwaveprop.Repo
alias Microwaveprop.Terrain
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
# T-Mobile CGNAT range — only enqueue enrichment jobs from this subnet
@enqueue_subnet {172, 56, 0, 0}
@enqueue_mask 13
@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,
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: BandConfig.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
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 = 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)
|> 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 = socket.assigns[:hrrr_path] || Weather.hrrr_profiles_for_path(contact)
elevation_profile = PathAnalysis.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 = PathAnalysis.compute_elevation_profile(contact, hrrr_path, soundings)
propagation_analysis =
build_propagation_analysis(contact, hrrr_path, terrain, elevation_profile, soundings)
data_sources =
PathAnalysis.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
# (fetch-on-demand via IEM soundings search with widening radius)
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 ->
from(j in Oban.Job,
where: j.state in ["available", "scheduled", "retryable", "executing"],
group_by: j.queue,
select: {j.queue, count(j.id)}
)
|> Repo.all()
|> Map.new()
end)
end
defp internal_network?(session) do
case Map.get(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}>
<.contact_header contact={@contact} current_scope={@current_scope} editing={@editing} />
<%= 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 %>
<.edit_form
editing={@editing}
current_scope={@current_scope}
contact={@contact}
edit_form={@edit_form}
band_options={@band_options}
mode_options={@mode_options}
/>
<.elevation_profile_panel
contact={@contact}
current_scope={@current_scope}
elevation_profile={@elevation_profile}
queue_counts={@queue_counts}
/>
<%= if @propagation_analysis do %>
<div class="divider" />
<.propagation_analysis_panel propagation_analysis={@propagation_analysis} />
<% end %>
<div class="divider" />
<.mechanism_panel mechanism={@mechanism} radar={@radar} />
<.terrain_profile_panel
terrain={@terrain}
terrain_expanded={@terrain_expanded}
hydration_pending={@hydration_pending}
contact={@contact}
queue_counts={@queue_counts}
/>
<div class="divider" />
<.soundings_panel
soundings={@soundings}
hydration_pending={@hydration_pending}
sounding_search_pending?={@sounding_search_pending?}
sounding_search_radius_km={@sounding_search_radius_km}
contact={@contact}
queue_counts={@queue_counts}
expanded_soundings={@expanded_soundings}
/>
<div class="divider" />
<.solar_panel solar={@solar} queue_counts={@queue_counts} />
<div class="divider" />
<.atmospheric_profile_panel
hrrr={@hrrr}
narr={@narr}
native_profile={@native_profile}
hrrr_profile_expanded={@hrrr_profile_expanded}
hydration_pending={@hydration_pending}
contact={@contact}
queue_counts={@queue_counts}
/>
<div class="divider" />
<.iemre_panel iemre={@iemre} contact={@contact} />
<div class="divider" />
<.surface_observations_panel
surface_observations={@surface_observations}
contact={@contact}
queue_counts={@queue_counts}
/>
<%= if @data_sources do %>
<div class="divider" />
<.data_sources_panel
data_sources={@data_sources}
surface_observations={@surface_observations}
soundings={@soundings}
solar={@solar}
/>
<% end %>
</Layouts.app>
"""
end
# ── Render Components ──────────────────────────────────────────
attr :contact, :map, required: true
attr :current_scope, :any, required: true
attr :editing, :boolean, required: true
defp contact_header(assigns) do
~H"""
<.header>
<span class={[@contact.flagged_invalid && "line-through opacity-50"]}>
{@contact.station1} / {@contact.station2}
</span>
<:subtitle>
{format_band(@contact.band)}
<%= if @contact.mode do %>
&middot; {@contact.mode}
<% end %>
&middot; {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>
"""
end
attr :editing, :boolean, required: true
attr :current_scope, :any, required: true
attr :contact, :map, required: true
attr :edit_form, :any
attr :band_options, :list, default: []
attr :mode_options, :list, default: []
defp edit_form(assigns) do
~H"""
<%= 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 %>
"""
end
attr :contact, :map, required: true
attr :current_scope, :any, required: true
attr :elevation_profile, :any
attr :queue_counts, :map, default: %{}
defp elevation_profile_panel(assigns) do
~H"""
<%= 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)} &middot; {@contact.mode} &middot; {format_dist(
@elevation_profile.dist_km
)} &middot; <.grid_link grid={@contact.grid1} /> &rarr; <.grid_link grid={@contact.grid2} />
</div>
<table class="text-sm font-mono mb-2 mx-auto">
<tr>
<td class="pr-6">{@contact.station1} &rarr; {@contact.station2}</td>
<td class="pr-6">Az: {format_bearing(@elevation_profile.fwd_az)}</td>
<td>El: {@elevation_profile.fwd_el}&deg;</td>
</tr>
<tr>
<td class="pr-6">{@contact.station2} &rarr; {@contact.station1}</td>
<td class="pr-6">Az: {format_bearing(@elevation_profile.rev_az)}</td>
<td>El: {@elevation_profile.rev_el}&deg;</td>
</tr>
</table>
<%= if @elevation_profile.verdict == "BLOCKED" && @elevation_profile.first_obstruction_km do %>
<div class="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 %>
"""
end
attr :propagation_analysis, :map, required: true
defp propagation_analysis_panel(assigns) do
~H"""
<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
attr :mechanism, :any
attr :radar, :any
defp mechanism_panel(assigns) do
~H"""
<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>
"""
end
attr :terrain, :any
attr :terrain_expanded, :boolean, default: false
attr :hydration_pending, :any, default: MapSet.new()
attr :contact, :map, required: true
attr :queue_counts, :map, default: %{}
defp terrain_profile_panel(assigns) do
~H"""
<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 %>
"""
end
attr :soundings, :list, default: []
attr :hydration_pending, :any, default: MapSet.new()
attr :sounding_search_pending?, :boolean, default: false
attr :sounding_search_radius_km, :any
attr :contact, :map, required: true
attr :queue_counts, :map, default: %{}
attr :expanded_soundings, :any, default: MapSet.new()
defp soundings_panel(assigns) do
~H"""
<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 %>
"""
end
attr :solar, :any
attr :queue_counts, :map, default: %{}
defp solar_panel(assigns) do
~H"""
<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 %>
"""
end
attr :hrrr, :any
attr :narr, :any
attr :native_profile, :any
attr :hrrr_profile_expanded, :boolean, default: false
attr :hydration_pending, :any, default: MapSet.new()
attr :contact, :map, required: true
attr :queue_counts, :map, default: %{}
defp atmospheric_profile_panel(assigns) do
~H"""
<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 %>
"""
end
attr :iemre, :any
attr :contact, :map, required: true
defp iemre_panel(assigns) do
~H"""
<h2 class="text-base font-semibold mb-2">IEMRE Gridded Reanalysis</h2>
<%= if @iemre do %>
<% h = @iemre.nearest_hour %>
<% obs = @iemre.observation %>
<div class="text-xs opacity-60 mb-2">
Grid point: {format_number(obs.lat)}°, {format_number(obs.lon)}° &mdash; {obs.date} hour {h[
"utc_hour"
] || "—"} UTC
</div>
<div class="flex flex-wrap gap-x-6 gap-y-1 text-sm">
<div><span class="font-semibold">Temp:</span> {format_number(h["air_temp_f"])}°F</div>
<div><span class="font-semibold">Dewpoint:</span> {format_number(h["dew_point_f"])}°F</div>
<div><span class="font-semibold">Sky Cover:</span> {format_number(h["skyc_%"])}%</div>
<div>
<span class="font-semibold">Wind:</span>
{format_number(wind_speed_from_components(h["uwnd_mps"], h["vwnd_mps"]))} kts
</div>
<div>
<span class="font-semibold">Precip:</span> {format_number(h["hourly_precip_in"])}"
</div>
<div :if={h["soil4t_f"]}>
<span class="font-semibold">Soil Temp:</span> {format_number(h["soil4t_f"])}°F
</div>
</div>
<% else %>
<div class="text-sm text-base-content/50 italic">
<%= case @contact.iemre_status do %>
<% :queued -> %>
<span class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span> Fetching IEMRE data
</span>
<% :unavailable -> %>
IEMRE data unavailable for this contact.
<% _ -> %>
No IEMRE data available.
<% end %>
</div>
<% end %>
"""
end
attr :surface_observations, :list, default: []
attr :contact, :map, required: true
attr :queue_counts, :map, default: %{}
defp surface_observations_panel(assigns) do
~H"""
<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 %>
"""
end
attr :data_sources, :map, required: true
attr :surface_observations, :list, default: []
attr :soundings, :list, default: []
attr :solar, :any
defp data_sources_panel(assigns) do
~H"""
<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
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}°"
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(verdict), do: Microwaveprop.Format.terrain_verdict_class(verdict)
defp haversine_km(lat1, lon1, lat2, lon2) do
Microwaveprop.Geo.haversine_km(lat1, lon1, lat2, lon2)
end
defp rem_float(a, b), do: a - Float.floor(a / b) * b
# ── 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 =
PathAnalysis.build_summary(terrain, elevation_profile, hrrr, soundings, dist_km, band_mhz, contact)
details = PathAnalysis.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
# 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: Microwaveprop.Format.propagation_tier_label(score)
defp propagation_tier_class(score), do: Microwaveprop.Format.propagation_tier_badge_class(score)
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