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:` 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""" <.header> {@contact.station1} / {@contact.station2} <: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 %> Flagged invalid{flag_attribution(@contact)} <% end %> <%= if @contact.private do %> <.icon name="hero-lock-closed" class="w-3 h-3 mr-1" /> Private <% end %> <:actions> <%= if current_user(assigns) do %> <% end %> <.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 navigate={~p"/contacts"} class="btn btn-sm btn-ghost"> <.icon name="hero-arrow-left" class="w-4 h-4" /> Back to Contacts <%= if @pending_edit && !@editing && !admin?(assigns) && !Radio.owner?(@contact, current_user(assigns)) do %>
<.icon name="hero-clock" class="w-4 h-4 inline" /> You have a pending edit for this contact awaiting admin review.
<% end %> <%= if @editing do %> <% direct_edit = admin?(assigns) or Radio.owner?(@contact, current_user(assigns)) %>

{if direct_edit, do: "Edit Contact", else: "Suggest Edit"}

{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."}

<.form for={@edit_form} id="edit-form" phx-change="validate_edit" phx-submit="submit_edit" class="space-y-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" />
<.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?" />
<.input field={@edit_form[:private]} type="checkbox" label="Private — only visible to me and administrators" />
<.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"}
<% end %> <%= if @contact.pos1 && @contact.pos2 do %>

Locations approximate, in the center of the grid squares. <%= if current_user(assigns) do %> Have more detailed position information? <% 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 <% end %>

<% end %> <%= if !@elevation_profile && @contact.terrain_status == :queued do %>
Computing elevation profile and terrain analysis {queue_info(@queue_counts, "terrain")}
<% end %> <%= if @elevation_profile do %>
{format_band(@contact.band)} · {@contact.mode} · {format_dist( @elevation_profile.dist_km )} · <.grid_link grid={@contact.grid1} /> → <.grid_link grid={@contact.grid2} />
{@contact.station1} → {@contact.station2} Az: {format_bearing(@elevation_profile.fwd_az)} El: {@elevation_profile.fwd_el}°
{@contact.station2} → {@contact.station1} Az: {format_bearing(@elevation_profile.rev_az)} El: {@elevation_profile.rev_el}°
<%= if @elevation_profile.verdict == "BLOCKED" && @elevation_profile.first_obstruction_km do %>
Obstructed at {format_dist(@elevation_profile.first_obstruction_km)}
<% end %> <%= if @elevation_profile.verdict == "CLEAR" do %>
Line of sight clear
<% end %>
<% end %> <%= if @propagation_analysis do %>

Propagation Analysis

{@propagation_analysis.summary}

<%= for detail <- @propagation_analysis.details do %>

{detail}

<% end %>
<%= if @propagation_analysis.factors do %>
<%= for f <- @propagation_analysis.factor_rows do %> <% end %>
Factor Score Weight Contribution Notes
{f.name} {f.score}/100 {f.weight}% {f.contribution} {f.note}
Composite {@propagation_analysis.composite_score}/100 {propagation_tier_label(@propagation_analysis.composite_score)}
<% end %> <% end %>

Propagation Mechanism

{Mechanism.label(@mechanism)} {Mechanism.explainer(@mechanism)}
<%= if @radar do %>
Common volume: {format_number(@radar.common_volume_km2, 0)} km² <%= if @radar.max_dbz do %> Max reflectivity: {format_number(@radar.max_dbz, 1)} dBZ <% end %> Heavy-rain pixels: {@radar.heavy_rain_pixel_count} <%= if @radar.coverage_pct do %> Radar coverage: {format_number(@radar.coverage_pct, 0)}% <% end %> Frame: {Calendar.strftime(@radar.observed_at, "%Y-%m-%d %H:%M UTC")}
<% end %>

Terrain Profile

<%= if @terrain do %>
<%= if @terrain_expanded do %>
<%= for {pt, idx} <- Enum.with_index(@terrain.path_points) do %> <% end %>
# Lat Lon Dist (km) Elev (m)
{idx} {format_number(pt["lat"])} {format_number(pt["lon"])} {format_number(pt["dist_km"])} {format_number(pt["elev"])}
<% end %>
<% else %>
<%= cond do %> <% MapSet.member?(@hydration_pending, :terrain) -> %> Loading terrain profile… <% @contact.terrain_status == :queued -> %> Computing terrain profile {queue_info(@queue_counts, "terrain")} <% true -> %> No terrain profile available. <% end %>
<% end %>

Soundings

<%= if @soundings == [] do %>
<%= cond do %> <% MapSet.member?(@hydration_pending, :weather) -> %> Loading soundings… <% @sounding_search_pending? -> %> Fetching soundings from IEM (search radius {@sounding_search_radius_km} km)… <% @contact.weather_status == :queued -> %> Fetching sounding data from nearby stations {queue_info(@queue_counts, "weather")} <% true -> %> No soundings found within 1000 km. <% end %>
<% else %> <%= for s <- @soundings do %>
<%= if MapSet.member?(@expanded_soundings, s.id) do %>
<%= for level <- filter_profile(s.profile) do %> <% end %>
Pressure (mb) Height (m) Temp (°C) Dewpoint (°C) Wind Dir (°) Wind Spd (kts)
{format_number(level["pres"])} {format_number(level["hght"])} {format_number(level["tmpc"])} {format_number(level["dwpc"])} {format_number(level["drct"])} {format_number(level["sknt"])}
<% end %>
<% end %> <% end %>

Solar Conditions

<%= if @solar do %>
SFI: {@solar.sfi || "—"}
Sunspot #: {@solar.sunspot_number || "—"}
Ap: {@solar.ap_index || "—"}
Kp: {format_kp(@solar.kp_values)}
<% else %>
Fetching solar index data {queue_info(@queue_counts, "solar")}
<% end %>

Atmospheric Profile

<% 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 %>
<%= if @hrrr_profile_expanded do %>
Surface Temp: {format_number(profile.surface_temp_c)}°C
Surface Dewpoint: {format_number(profile.surface_dewpoint_c)}°C
Surface Pressure: {format_number(profile.surface_pressure_mb)} mb
<%= 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 %>
<%= if skew_t_levels != [] do %> <%= if @native_profile do %>
Skew-T source: {skew_t_source}
<% end %> <.skew_t_chart profile={skew_t_levels} />
<%= for level <- skew_t_levels do %> <% end %>
Pressure (mb) Height (m) Temp (°C) Dewpoint (°C)
{format_number(level["pres"])} {format_number(level["hght"])} {format_number(level["tmpc"])} {format_number(level["dwpc"])}
<% end %>
<% end %>
<% else %>
<%= cond do %> <% atmospheric_loading?(@hydration_pending) -> %> Loading atmospheric profile… <% @contact.hrrr_status == :queued -> %> Fetching HRRR atmospheric data {queue_info(@queue_counts, "hrrr")} <% @contact.hrrr_status == :unavailable -> %> HRRR unavailable — fetching NARR reanalysis {queue_info(@queue_counts, "narr")} <% true -> %> No atmospheric profile available. <% end %>
<% end %>

IEMRE Gridded Reanalysis

<%= if @iemre do %> <% h = @iemre.nearest_hour %> <% obs = @iemre.observation %>
Grid point: {format_number(obs.lat)}°, {format_number(obs.lon)}° — {obs.date} hour {h[ "utc_hour" ] || "—"} UTC
Temp: {format_number(h["air_temp_f"])}°F
Dewpoint: {format_number(h["dew_point_f"])}°F
Sky Cover: {format_number(h["skyc_%"])}%
Wind: {format_number(wind_speed_from_components(h["uwnd_mps"], h["vwnd_mps"]))} kts
Precip: {format_number(h["hourly_precip_in"])}"
Soil Temp: {format_number(h["soil4t_f"])}°F
<% else %>
<%= case @contact.iemre_status do %> <% :queued -> %> Fetching IEMRE data <% :unavailable -> %> IEMRE data unavailable for this contact. <% _ -> %> No IEMRE data available. <% end %>
<% end %>

Surface Observations

<%= if @surface_observations == [] do %>
<%= if @contact.weather_status == :queued do %> Fetching surface observations from nearby stations {queue_info(@queue_counts, "weather")}... <% else %> No surface observations found nearby. <% end %>
<% else %>
<%= for obs <- @surface_observations do %> <% end %>
Station Time Temp Dewpt RH% Wind Pres (mb) Sky
{obs.station.name || obs.station.station_code} {Calendar.strftime(obs.observed_at, "%H:%M")} {obs.temp_f || "—"} {obs.dewpoint_f || "—"} {format_number(obs.relative_humidity)} {format_wind(obs)} {obs.sea_level_pressure_mb || "—"} {obs.sky_condition || "—"}
<% end %> <%= if @data_sources do %>

Data Sources

HRRR Model
<%= if @data_sources.hrrr do %>
{@data_sources.hrrr.profile_count} path profiles
{@data_sources.hrrr.levels} pressure levels each
Valid: {@data_sources.hrrr.valid_time}
Grid: {@data_sources.hrrr.lat}, {@data_sources.hrrr.lon}
<% else %>
Not available
<% end %>
Elevation Profile
<%= if @data_sources.elevation do %>
{@data_sources.elevation.samples} samples along path
{@data_sources.elevation.dist} path distance
Source: {@data_sources.elevation.source}
{@data_sources.elevation.duct_count} duct layers detected
<% else %>
Not available
<% end %>
Terrain Analysis
<%= if @data_sources.terrain do %>
{@data_sources.terrain.samples} profile samples
Verdict: {@data_sources.terrain.verdict}
Max elevation: {@data_sources.terrain.max_elev} m
Diffraction: {@data_sources.terrain.diffraction} dB
<% else %>
Not available
<% end %>
Surface Observations
{length(@surface_observations)} stations
<%= if @surface_observations != [] do %>
{Calendar.strftime(hd(@surface_observations).observed_at, "%H:%M")} – {Calendar.strftime( List.last(@surface_observations).observed_at, "%H:%M" )} UTC
<% end %>
Soundings (RAOB)
{length(@soundings)} profiles
<%= if @soundings != [] do %>
{Enum.map_join(@soundings, ", ", fn s -> "#{s.station.station_code} (#{s.level_count} levels)" end)}
{Enum.count(@soundings, & &1.ducting_detected)} with ducting
<% end %>
Solar Indices
<%= if @solar do %>
SFI: {@solar.sfi || "—"}
SSN: {@solar.sunspot_number || "—"}
Ap: {@solar.ap_index || "—"}
<% else %>
Not available
<% end %>
<% end %> """ 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 %> {@grid} <% else %> — <% end %> """ end end