defmodule MicrowavepropWeb.ContactLive.Show do @moduledoc false use MicrowavepropWeb, :live_view alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Propagation.Scorer alias Microwaveprop.Radio alias Microwaveprop.Terrain alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.Terrain.TerrainAnalysis alias Microwaveprop.Weather alias Microwaveprop.Weather.HrrrClient alias Microwaveprop.Workers.ContactWeatherEnqueueWorker alias Microwaveprop.Workers.HrrrFetchWorker alias Microwaveprop.Workers.SolarIndexWorker alias Microwaveprop.Workers.TerrainProfileWorker require Logger @earth_radius_m 6_371_000.0 # 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 = id |> Radio.get_contact!() |> Radio.ensure_positions!() can_enqueue = internal_network?(session) if connected?(socket) do Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:#{contact.id}") Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:hrrr") Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:weather") Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:solar") end weather = load_weather(contact) solar = if can_enqueue, do: maybe_enqueue_solar(contact), else: load_solar(contact) hrrr_path = Weather.hrrr_profiles_for_path(contact) hrrr = List.first(hrrr_path) {hrrr, contact} = if can_enqueue, do: maybe_enqueue_hrrr(hrrr, contact), else: {hrrr, contact} terrain = Terrain.get_terrain_profile(contact.id) contact = if can_enqueue, do: maybe_enqueue_terrain(terrain, contact), else: contact contact = if can_enqueue, do: maybe_enqueue_weather(weather, contact), else: contact iemre = load_iemre(contact) elevation_profile = compute_elevation_profile(contact, hrrr_path, weather.soundings) propagation_analysis = build_propagation_analysis(contact, hrrr_path, terrain, elevation_profile, weather.soundings) data_sources = build_data_sources(hrrr, hrrr_path, terrain, weather, elevation_profile) {:ok, assign(socket, page_title: "#{contact.station1} / #{contact.station2}", contact: contact, surface_observations: weather.surface_observations, soundings: weather.soundings, solar: solar, hrrr: hrrr, iemre: iemre, terrain: terrain, elevation_profile: elevation_profile, propagation_analysis: propagation_analysis, data_sources: data_sources, terrain_expanded: false, hrrr_profile_expanded: false, obs_sort_by: "station_name", obs_sort_order: "asc", sounding_sort_by: "station_name", sounding_sort_order: "asc", queue_counts: fetch_queue_counts(), expanded_soundings: MapSet.new(), editing: false, edit_form: nil, pending_edit: load_pending_edit(contact, socket), band_options: @band_options, mode_options: @mode_options )} end 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("toggle_flag", _params, socket) do contact = Radio.toggle_flagged_invalid!(socket.assigns.contact) {:noreply, assign(socket, contact: contact)} end def handle_event("toggle_profile", %{"id" => id}, socket) do expanded = if MapSet.member?(socket.assigns.expanded_soundings, id) do MapSet.delete(socket.assigns.expanded_soundings, id) else MapSet.put(socket.assigns.expanded_soundings, id) end {:noreply, assign(socket, expanded_soundings: expanded)} end def handle_event("sort", %{"field" => field, "table" => "soundings"}, socket) do {sort_by, sort_order} = toggle_sort(socket.assigns.sounding_sort_by, socket.assigns.sounding_sort_order, field) sorted = sort_soundings(socket.assigns.soundings, sort_by, sort_order) {:noreply, assign(socket, soundings: sorted, sounding_sort_by: sort_by, sounding_sort_order: sort_order )} end 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-%dT%H:%M"), else: "") } {: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 contact = socket.assigns.contact if admin?(socket.assigns) do # Admin: apply changes directly proposed = Radio.diff_against_contact(contact, Radio.normalize_proposed(params)) if proposed == %{} do {:noreply, put_flash(socket, :error, "No changes detected.")} else Radio.apply_edit_to_contact(contact, proposed) updated = Radio.get_contact!(contact.id) {:noreply, socket |> assign(contact: updated, editing: false, edit_form: nil) |> put_flash(:info, "Contact updated.")} end else case Radio.create_contact_edit(contact, user, params) do {:ok, _edit} -> {:noreply, 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(", ") {:noreply, put_flash(socket, :error, "Could not submit edit: #{messages}")} end end end end @impl true def handle_info({:terrain_ready, _contact_id}, socket) do contact = %{socket.assigns.contact | terrain_status: :complete} terrain = Terrain.get_terrain_profile(contact.id) soundings = socket.assigns.soundings hrrr_path = Weather.hrrr_profiles_for_path(contact) 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 def handle_info({:hrrr_ready, _info}, socket) do contact = socket.assigns.contact hrrr_path = Weather.hrrr_profiles_for_path(contact) hrrr = List.first(hrrr_path) if hrrr do contact = %{contact | hrrr_status: :complete} soundings = socket.assigns.soundings terrain = socket.assigns.terrain elevation_profile = compute_elevation_profile(contact, hrrr_path, soundings) propagation_analysis = build_propagation_analysis(contact, hrrr_path, terrain, elevation_profile, soundings) {:noreply, assign(socket, contact: contact, hrrr: hrrr, queue_counts: fetch_queue_counts(), elevation_profile: elevation_profile, propagation_analysis: propagation_analysis )} else {:noreply, socket} end end def handle_info({:weather_ready, _info}, socket) do contact = %{socket.assigns.contact | weather_status: :complete} weather = load_weather(contact) soundings = weather.soundings hrrr_path = Weather.hrrr_profiles_for_path(contact) terrain = socket.assigns.terrain 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, surface_observations: weather.surface_observations, soundings: soundings, elevation_profile: elevation_profile, propagation_analysis: propagation_analysis )} end def handle_info({:solar_ready, _date}, socket) do solar = load_solar(socket.assigns.contact) {:noreply, assign(socket, solar: solar)} end defp maybe_enqueue_weather(weather, contact) do has_data = weather.surface_observations != [] or weather.soundings != [] cond do has_data -> if contact.weather_status != :complete do Radio.set_enrichment_status!([contact.id], :weather_status, :complete) end %{contact | weather_status: :complete} contact.pos1 && contact.weather_status != :queued -> jobs = ContactWeatherEnqueueWorker.build_weather_jobs([contact]) if jobs == [] do %{contact | weather_status: :unavailable} else Oban.insert_all(jobs) Radio.set_enrichment_status!([contact.id], :weather_status, :queued) %{contact | weather_status: :queued} end is_nil(contact.pos1) -> %{contact | weather_status: :unavailable} true -> contact end end defp toggle_sort(current_field, current_order, new_field) do if current_field == new_field && current_order == "asc", do: {new_field, "desc"}, else: {new_field, "asc"} end defp sort_observations(observations, sort_by, sort_order) do sorter = obs_sort_key(sort_by) sorted = Enum.sort_by(observations, sorter) if sort_order == "desc", do: Enum.reverse(sorted), else: sorted end defp obs_sort_key("station_name"), do: fn obs -> obs.station.name || obs.station.station_code end defp obs_sort_key("observed_at"), do: & &1.observed_at defp obs_sort_key("temp_f"), do: &(&1.temp_f || 0) defp obs_sort_key("dewpoint_f"), do: &(&1.dewpoint_f || 0) defp obs_sort_key("relative_humidity"), do: &(&1.relative_humidity || 0) defp obs_sort_key("sea_level_pressure_mb"), do: &(&1.sea_level_pressure_mb || 0) defp obs_sort_key(_), do: fn obs -> obs.station.name || obs.station.station_code end defp sort_soundings(soundings, sort_by, sort_order) do sorter = sounding_sort_key(sort_by) sorted = Enum.sort_by(soundings, sorter) if sort_order == "desc", do: Enum.reverse(sorted), else: sorted end defp sounding_sort_key("station_name"), do: fn s -> s.station.name || s.station.station_code end defp sounding_sort_key("observed_at"), do: & &1.observed_at defp sounding_sort_key("surface_temp_c"), do: &(&1.surface_temp_c || 0) defp sounding_sort_key("surface_refractivity"), do: &(&1.surface_refractivity || 0) defp sounding_sort_key("k_index"), do: &(&1.k_index || 0) defp sounding_sort_key("lifted_index"), do: &(&1.lifted_index || 0) defp sounding_sort_key(_), do: fn s -> s.station.name || s.station.station_code end defp load_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 lat1 = contact.pos1 && contact.pos1["lat"] lon1 = contact.pos1 && (contact.pos1["lon"] || contact.pos1["lng"]) lat2 = contact.pos2 && contact.pos2["lat"] lon2 = contact.pos2 && (contact.pos2["lon"] || contact.pos2["lng"]) if lat1 && lon1 do # Search along the path midpoint with radius covering both endpoints mid_lat = if lat2, do: (lat1 + lat2) / 2, else: lat1 mid_lon = if lon2, do: (lon1 + lon2) / 2, else: lon1 half_dist = if lat2 && lon2, do: haversine_km(lat1, lon1, lat2, lon2) / 2, else: 0 radius = max(50, half_dist + 50) weather = Weather.weather_for_contact( %{lat: mid_lat, lon: mid_lon, timestamp: contact.qso_timestamp}, radius_km: radius ) # Keep only the 5 closest stations by distance to path midpoint %{ surface_observations: weather.surface_observations |> Enum.sort_by(fn obs -> slat = obs.station.lat slon = obs.station.lon abs(slat - mid_lat) + abs(slon - mid_lon) end) |> Enum.take(5), soundings: weather.soundings } else %{surface_observations: [], soundings: []} end end defp maybe_enqueue_terrain(terrain, contact) when not is_nil(terrain) do if contact.terrain_status != :complete do Radio.set_enrichment_status!([contact.id], :terrain_status, :complete) end %{contact | terrain_status: :complete} end defp maybe_enqueue_terrain(nil, contact) do cond do contact.pos1 && contact.pos2 && contact.terrain_status != :queued -> case Oban.insert(TerrainProfileWorker.new(%{"contact_id" => contact.id})) do {:ok, %{conflict?: false}} -> Radio.set_enrichment_status!([contact.id], :terrain_status, :queued) %{contact | terrain_status: :queued} _ -> %{contact | terrain_status: :unavailable} end is_nil(contact.pos1) or is_nil(contact.pos2) -> %{contact | terrain_status: :unavailable} true -> contact end end defp maybe_enqueue_hrrr(hrrr, contact) when not is_nil(hrrr) do if contact.hrrr_status != :complete do Radio.set_enrichment_status!([contact.id], :hrrr_status, :complete) end {hrrr, %{contact | hrrr_status: :complete}} end defp maybe_enqueue_hrrr(nil, contact) do lat = contact.pos1 && contact.pos1["lat"] lon = contact.pos1 && (contact.pos1["lon"] || contact.pos1["lng"]) if lat && lon && contact.hrrr_status != :queued do valid_time = HrrrClient.nearest_hrrr_hour(contact.qso_timestamp) {rlat, rlon} = Weather.round_to_hrrr_grid(lat, lon) case Oban.insert( HrrrFetchWorker.new(%{ "lat" => rlat, "lon" => rlon, "valid_time" => DateTime.to_iso8601(valid_time) }) ) do {:ok, %{conflict?: false}} -> Radio.set_enrichment_status!([contact.id], :hrrr_status, :queued) {nil, %{contact | hrrr_status: :queued}} _ -> # Unique conflict — job already ran recently, mark unavailable Radio.set_enrichment_status!([contact.id], :hrrr_status, :unavailable) {nil, %{contact | hrrr_status: :unavailable}} end else {nil, contact} end end defp queue_info(counts, queue) do case Map.get(counts, queue, 0) do 0 -> "" 1 -> "(processing)" n -> "(#{n} jobs ahead)" end end defp fetch_queue_counts do import Ecto.Query from(j in Oban.Job, where: j.state in ["available", "scheduled", "retryable", "executing"], group_by: j.queue, select: {j.queue, count(j.id)} ) |> Microwaveprop.Repo.all() |> Map.new() end defp 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 maybe_enqueue_solar(contact) do solar = load_solar(contact) if solar do solar else date = DateTime.to_date(contact.qso_timestamp) Oban.insert(SolarIndexWorker.new(%{"date" => Date.to_iso8601(date)})) nil end end @impl true def render(assigns) do ~H""" <.header> {@contact.station1} / {@contact.station2} <:subtitle> {Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")} <%= if @contact.flagged_invalid do %> Flagged Invalid <% end %> <:actions> <%= if current_user(assigns) do %> <% end %> <.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) 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 %>

{if admin?(assigns), do: "Edit Contact", else: "Suggest Edit"}

{if admin?(assigns), do: "Change any fields below. Changes will be applied immediately.", 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[:station2]} type="text" label="Station 2" /> <.input field={@edit_form[:grid2]} type="text" label="Grid 2" />
<.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="datetime-local" label="Timestamp (UTC)" lang="en-GB" />
<.button type="submit" class="btn btn-primary btn-sm"> <.icon name="hero-paper-airplane" class="w-4 h-4" /> {if admin?(assigns), do: "Save Changes", else: "Submit for Review"}
<% end %> <%= if @contact.pos1 && @contact.pos2 do %>

Locations approximate, in the center of the grid squares

<% 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_ghz(@contact.band)} · {@contact.mode} · {format_dist( @elevation_profile.dist_km )} · {@contact.grid1 || "—"} → {@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 %>

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 %>
<%= if @contact.terrain_status == :queued do %> Computing terrain profile {queue_info(@queue_counts, "terrain")} <% else %> No terrain profile available. <% end %>
<% end %>

Soundings

<%= if @soundings == [] do %>
<%= if @contact.weather_status == :queued do %> Fetching sounding data from nearby stations {queue_info(@queue_counts, "weather")} <% else %> No soundings found nearby. <% 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 %>

HRRR Model Profile

<%= if @hrrr do %>
<%= if @hrrr_profile_expanded do %>
Surface Temp: {format_number(@hrrr.surface_temp_c)}°C
Surface Dewpoint: {format_number(@hrrr.surface_dewpoint_c)}°C
Surface Pressure: {format_number(@hrrr.surface_pressure_mb)} mb
Run Time: {if @hrrr.run_time, do: Calendar.strftime(@hrrr.run_time, "%Y-%m-%d %H:%M UTC"), else: "—"}
<%= if @hrrr.profile && @hrrr.profile != [] do %>
<%= for level <- @hrrr.profile 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 %>
<%= case @contact.hrrr_status do %> <% :queued -> %> Fetching HRRR atmospheric data {queue_info(@queue_counts, "hrrr")} <% :unavailable -> %> HRRR data unavailable for this contact time. <% _ -> %> No HRRR 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 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 mi = km * 0.621371 "#{:erlang.float_to_binary(mi, decimals: 1)} mi (#{:erlang.float_to_binary(km, decimals: 1)} km)" end defp format_bearing(deg) do whole = trunc(deg) frac = round((deg - whole) * 10) "#{String.pad_leading(to_string(whole), 3, "0")}.#{frac}\u00B0" end defp format_band_ghz(nil), do: "—" defp format_band_ghz(band) do mhz = Decimal.to_float(band) ghz = mhz / 1000 if ghz == Float.round(ghz, 0) do "#{trunc(ghz)} GHz" else "#{:erlang.float_to_binary(ghz, decimals: 1)} GHz" end end defp format_wind(obs) do case {obs.wind_direction_deg, obs.wind_speed_kts} do {nil, nil} -> "—" {dir, speed} -> "#{dir || "?"}° @ #{speed || "?"} kts" end end defp filter_profile(profile) do Enum.filter(profile, fn level -> level["tmpc"] != nil || level["dwpc"] != nil end) end defp format_kp(nil), do: "—" defp format_kp(values) when is_list(values), do: Enum.map_join(values, ", ", &format_number/1) defp terrain_verdict_class("CLEAR"), do: "badge-success" defp terrain_verdict_class("FRESNEL_MINOR"), do: "badge-warning" defp terrain_verdict_class("FRESNEL_PARTIAL"), do: "badge-warning" defp terrain_verdict_class("BLOCKED"), do: "badge-error" defp terrain_verdict_class(_), do: "badge-ghost" defp compute_elevation_profile(contact, hrrr_path, soundings) do with %{"lat" => lat1} <- contact.pos1, lon1 when is_number(lon1) <- contact.pos1["lon"] || contact.pos1["lng"], %{"lat" => lat2} <- contact.pos2, lon2 when is_number(lon2) <- contact.pos2["lon"] || contact.pos2["lng"], {:ok, profile} <- safe_fetch_elevation(lat1, lon1, lat2, lon2) do freq_ghz = band_to_ghz(contact.band) dist_km = haversine_km(lat1, lon1, lat2, lon2) analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz) fwd_az = initial_bearing(lat1, lon1, lat2, lon2) rev_az = initial_bearing(lat2, lon2, lat1, lon1) tx_elev = hd(profile).elev rx_elev = List.last(profile).elev fwd_el = elevation_angle(tx_elev, rx_elev, dist_km * 1000) rev_el = elevation_angle(rx_elev, tx_elev, dist_km * 1000) first_obs = case Enum.find(analysis.points, & &1.obstructed) do nil -> nil p -> p.dist_km end ducts = hrrr_path |> extract_ducts(soundings, tx_elev) |> merge_nearby_ducts() |> Enum.sort_by(& &1.strength, :desc) |> Enum.take(3) |> mark_likely_duct(tx_elev, rx_elev) %{ points: Enum.map(analysis.points, fn p -> %{elev: p.elev, beam: p.beam, r1: p.r1, dist_km: p.dist_km} end), freq_mhz: freq_ghz * 1000, dist_km: dist_km, 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) if hrrr_ducts == [] do sounding_ducts = extract_sounding_ducts(soundings, surface_elev) if sounding_ducts == [] do # Use the profile with the strongest (most negative) gradient best = hrrr_path |> Enum.filter(&is_number(&1.min_refractivity_gradient)) |> Enum.min_by(& &1.min_refractivity_gradient, fn -> nil end) infer_duct_from_gradient(best, surface_elev) else sounding_ducts end else hrrr_ducts end end defp extract_hrrr_ducts(hrrr_path, surface_elev) when is_list(hrrr_path) do hrrr_path |> Enum.flat_map(fn h -> case h.duct_characteristics do ducts when is_list(ducts) and ducts != [] -> Enum.map(ducts, fn d -> %{ base_m_msl: surface_elev + (d["base"] || 0), top_m_msl: surface_elev + (d["top"] || 0), strength: d["strength"], source: "hrrr" } end) _ -> [] end end) |> Enum.uniq_by(fn d -> {round(d.base_m_msl), round(d.top_m_msl)} end) end defp extract_sounding_ducts(soundings, surface_elev) when is_list(soundings) do soundings |> Enum.filter(& &1.ducting_detected) |> Enum.flat_map(fn s -> case s.duct_characteristics do ducts when is_list(ducts) and ducts != [] -> Enum.map(ducts, fn d -> %{ base_m_msl: surface_elev + (d["base"] || 0), top_m_msl: surface_elev + (d["top"] || 0), strength: d["strength"], source: "sounding" } end) _ -> [] end end) |> Enum.uniq_by(fn d -> {round(d.base_m_msl), round(d.top_m_msl)} end) end defp extract_sounding_ducts(_, _), do: [] # HRRR pressure levels are too coarse (~250m) to detect thin ducting layers # via the M-profile method. When min_refractivity_gradient indicates enhanced # propagation, infer a duct layer within the boundary layer. defp infer_duct_from_gradient(nil, _surface_elev), do: [] defp infer_duct_from_gradient(hrrr, surface_elev) do grad = hrrr.min_refractivity_gradient hpbl = hrrr.hpbl_m if is_number(grad) and grad < -100 and is_number(hpbl) and hpbl > 0 do duct_top = min(hpbl * 0.6, 500) strength = abs(grad) / 10 [ %{ base_m_msl: surface_elev, top_m_msl: surface_elev + duct_top, strength: Float.round(strength, 1), source: "inferred" } ] else [] end end # Merge duct layers that overlap or are within 100m of each other defp merge_nearby_ducts(ducts) do ducts |> Enum.sort_by(& &1.base_m_msl) |> Enum.reduce([], fn duct, acc -> case acc do [prev | rest] when duct.base_m_msl <= prev.top_m_msl + 100 -> merged = %{ prev | top_m_msl: max(prev.top_m_msl, duct.top_m_msl), strength: max(prev.strength, duct.strength) } [merged | rest] _ -> [duct | acc] end end) |> Enum.reverse() end # Mark the duct most likely carrying the signal. The signal enters at # surface level from each end, so the duct whose base is closest to # the average endpoint elevation is the most probable propagation path. defp mark_likely_duct([], _tx_elev, _rx_elev), do: [] defp mark_likely_duct(ducts, tx_elev, rx_elev) do avg_elev = (tx_elev + rx_elev) / 2 {likely_idx, _} = ducts |> Enum.with_index() |> Enum.min_by(fn {d, _idx} -> # Distance from average endpoint elevation to the duct layer # Prefer ducts that the endpoints are actually inside of if avg_elev >= d.base_m_msl and avg_elev <= d.top_m_msl do -d.strength else min(abs(d.base_m_msl - avg_elev), abs(d.top_m_msl - avg_elev)) end end) Enum.with_index(ducts, fn d, i -> Map.put(d, :likely, i == likely_idx) end) end defp safe_fetch_elevation(lat1, lon1, lat2, lon2) do ElevationClient.fetch_elevation_profile(lat1, lon1, lat2, lon2, 256) rescue e -> Logger.warning("Elevation profile failed: #{Exception.message(e)}") {:error, :elevation_unavailable} end defp band_to_ghz(nil), do: 10.0 defp band_to_ghz(band) do band |> Decimal.to_float() |> Kernel./(1000) end defp haversine_km(lat1, lon1, lat2, lon2) do dlat = deg_to_rad(lat2 - lat1) dlon = deg_to_rad(lon2 - lon1) rlat1 = deg_to_rad(lat1) rlat2 = deg_to_rad(lat2) a = :math.sin(dlat / 2) ** 2 + :math.cos(rlat1) * :math.cos(rlat2) * :math.sin(dlon / 2) ** 2 2 * 6371.0 * :math.asin(:math.sqrt(a)) end defp initial_bearing(lat1, lon1, lat2, lon2) do rlat1 = deg_to_rad(lat1) rlat2 = deg_to_rad(lat2) dlon = deg_to_rad(lon2 - lon1) y = :math.sin(dlon) * :math.cos(rlat2) x = :math.cos(rlat1) * :math.sin(rlat2) - :math.sin(rlat1) * :math.cos(rlat2) * :math.cos(dlon) bearing = y |> :math.atan2(x) |> rad_to_deg() Float.round(rem_float(bearing + 360, 360), 1) end defp elevation_angle(h_tx, h_rx, dist_m) do k_r = 4 / 3 * @earth_radius_m angle = :math.atan2(h_rx - h_tx, dist_m) - dist_m / (2 * k_r) Float.round(rad_to_deg(angle), 2) end defp deg_to_rad(deg), do: deg * :math.pi() / 180 defp rad_to_deg(rad), do: rad * 180 / :math.pi() defp rem_float(a, b), do: a - Float.floor(a / b) * b # ── Data sources summary ──────────────────────────────────────── defp build_data_sources(hrrr, hrrr_path, terrain, weather, elevation_profile) do %{ hrrr: build_hrrr_source(hrrr, hrrr_path), elevation: build_elevation_source(elevation_profile), terrain: build_terrain_source(terrain), obs_count: length(weather.surface_observations), sounding_count: length(weather.soundings) } end defp build_hrrr_source(nil, _), do: nil defp build_hrrr_source(hrrr, hrrr_path) do levels = if hrrr.profile, do: length(hrrr.profile), else: 0 %{ profile_count: length(hrrr_path), levels: levels, valid_time: Calendar.strftime(hrrr.valid_time, "%Y-%m-%d %H:%M UTC"), lat: :erlang.float_to_binary(hrrr.lat / 1, decimals: 2), lon: :erlang.float_to_binary(hrrr.lon / 1, decimals: 2) } end defp build_elevation_source(nil), do: nil defp build_elevation_source(ep) do %{ samples: length(ep.points), dist: format_dist(ep.dist_km), source: "SRTM 1-arcsecond", duct_count: length(ep.ducts) } end defp build_terrain_source(nil), do: nil defp build_terrain_source(terrain) do %{ samples: terrain.sample_count, verdict: terrain.verdict, max_elev: :erlang.float_to_binary((terrain.max_elevation_m || 0) / 1, decimals: 0), diffraction: :erlang.float_to_binary((terrain.diffraction_db || 0) / 1, decimals: 1) } end # ── Propagation analysis ────────────────────────────────────────── defp build_propagation_analysis(contact, hrrr_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) details = build_details(hrrr, soundings, elevation_profile, band_mhz) %{ summary: summary, details: details, factors: factors, factor_rows: factor_rows, composite_score: composite } end defp compute_factors([], _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() 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) do terrain_status = terrain_summary(terrain, elevation_profile) mechanism = propagation_mechanism(terrain, elevation_profile, hrrr, soundings, dist_km) band_note = band_summary(band_mhz, hrrr) [terrain_status, mechanism, band_note] |> Enum.reject(&is_nil/1) |> Enum.join(" ") end defp terrain_summary(nil, nil), do: "No terrain data available." defp terrain_summary(terrain, elevation_profile) do verdict = (elevation_profile && elevation_profile.verdict) || (terrain && terrain.verdict) case verdict do "CLEAR" -> "Line of sight is clear between stations." "FRESNEL_MINOR" -> "Line of sight is clear but with minor Fresnel zone encroachment." "FRESNEL_PARTIAL" -> "Line of sight has partial Fresnel zone obstruction." "BLOCKED" -> obs_dist = elevation_profile && elevation_profile.first_obstruction_km if obs_dist do "Path is terrain-obstructed at #{:erlang.float_to_binary(obs_dist * 0.621371, decimals: 1)} mi (#{:erlang.float_to_binary(obs_dist, decimals: 1)} km) from station 1." else "Path is terrain-obstructed." end _ -> nil end end defp propagation_mechanism(terrain, elevation_profile, hrrr, soundings, dist_km) do blocked? = terrain && terrain.verdict == "BLOCKED" ducting? = has_ducting?(hrrr, soundings) enhanced_refraction? = hrrr != nil && is_number(hrrr.min_refractivity_gradient) && hrrr.min_refractivity_gradient < -100 long_path? = dist_km && dist_km > 100 ducts = (elevation_profile && elevation_profile.ducts) || [] likely_duct = Enum.find(ducts, & &1[:likely]) cond do blocked? && ducting? && likely_duct -> "Signal likely propagated via atmospheric ducting (#{duct_description(likely_duct)}), bending over the terrain obstruction." blocked? && ducting? -> "Ducting conditions detected — signal likely propagated through a tropospheric duct, bypassing the terrain obstruction." blocked? && enhanced_refraction? -> grad = round(hrrr.min_refractivity_gradient) "Enhanced refraction (dN/dh = #{grad} N-units/km) likely bent the signal over the obstruction. Conditions are super-refractive but below full ducting threshold." blocked? -> diff_db = terrain && terrain.diffraction_db if diff_db && diff_db > 0 do "Path is obstructed with #{:erlang.float_to_binary(diff_db, decimals: 1)} dB diffraction loss. Contact may have been enabled by knife-edge diffraction or tropospheric scatter." else "Path is obstructed. Contact likely enabled by tropospheric scatter or transient ducting conditions." end ducting? && long_path? && likely_duct -> "Ducting conditions present (#{duct_description(likely_duct)}) — likely extending range beyond normal line of sight." ducting? && long_path? -> "Ducting conditions present — likely contributing to extended range." enhanced_refraction? && long_path? -> "Enhanced refraction present — may have contributed to extended range." true -> nil end end defp duct_description(%{source: source, base_m_msl: base, top_m_msl: top, strength: strength}) do base_ft = round(base * 3.28084) top_ft = round(top * 3.28084) src = case source do "sounding" -> "sounding-detected" "inferred" -> "estimated" _ -> "detected" end "#{src} layer at #{base_ft}-#{top_ft} ft, #{strength} M-units" end defp duct_description(_), do: "detected layer" defp has_ducting?(hrrr, soundings) do hrrr_ducting = hrrr && hrrr.ducting_detected sounding_ducting = is_list(soundings) && Enum.any?(soundings, & &1.ducting_detected) hrrr_ducting || sounding_ducting end defp band_summary(band_mhz, hrrr) when band_mhz <= 12_000 do if hrrr && is_number(hrrr.pwat_mm) && hrrr.pwat_mm > 20 do "At 10 GHz, the elevated moisture (PWAT #{:erlang.float_to_binary(hrrr.pwat_mm, decimals: 1)} mm) enhances refractivity and ducting potential." end end defp band_summary(band_mhz, hrrr) when band_mhz >= 24_000 do if hrrr && is_number(hrrr.pwat_mm) && hrrr.pwat_mm > 25 do "At #{round(band_mhz / 1000)} GHz, high moisture (PWAT #{:erlang.float_to_binary(hrrr.pwat_mm, decimals: 1)} mm) causes significant water vapor absorption." end end defp band_summary(_, _), do: nil defp build_details(hrrr, soundings, elevation_profile, _band_mhz) do details = [] details = if hrrr && is_number(hrrr.min_refractivity_gradient) do grad = round(hrrr.min_refractivity_gradient) label = cond do grad < -157 -> "ducting" grad < -100 -> "super-refractive" grad < -79 -> "enhanced" true -> "normal" end details ++ ["Refractivity gradient: #{grad} N-units/km (#{label})."] else details end details = if hrrr && is_number(hrrr.hpbl_m) do hpbl = round(hrrr.hpbl_m) note = if hpbl < 300, do: " — shallow boundary layer favors ducting", else: "" details ++ ["Boundary layer depth: #{hpbl} m#{note}."] else details end ducting_soundings = if is_list(soundings), do: Enum.filter(soundings, & &1.ducting_detected), else: [] details = if ducting_soundings == [] do details else names = Enum.map_join(ducting_soundings, ", ", fn s -> s.station.name || s.station.station_code end) details ++ ["Ducting detected by soundings at: #{names}."] end details = if elevation_profile do ducts = elevation_profile.ducts || [] likely = Enum.find(ducts, & &1[:likely]) if likely do details ++ ["Most likely propagation duct: #{duct_description(likely)}."] else details end else details end details end # Factor note helpers defp humidity_note(abs_h, band_config) do h = Float.round(abs_h, 1) effect = if band_config.humidity_effect == :beneficial, do: "beneficial", else: "harmful" "#{h} g/m³ (#{effect} at this band)" end defp time_note(ts, lon) do solar_hour = ts.hour + ts.minute / 60 + lon / 15 solar_hour = rem_float(solar_hour + 24, 24) h = round(solar_hour) label = cond do h >= 4 and h < 8 -> "dawn" h >= 8 and h < 12 -> "morning" h >= 12 and h < 17 -> "afternoon" h >= 17 and h < 21 -> "evening" true -> "night" end "Solar hour ~#{h} (#{label})" end defp td_note(temp_f, dewpoint_f) when is_number(temp_f) and is_number(dewpoint_f) do depression = Float.round(temp_f - dewpoint_f, 1) label = cond do depression < 3 -> "near saturation" depression < 8 -> "moist" depression < 15 -> "moderate" true -> "dry" end "T-Td = #{depression}°F (#{label})" end defp td_note(_, _), do: "—" defp refractivity_note(nil), do: "—" defp refractivity_note(grad) do g = round(grad) label = cond do g < -157 -> "ducting" g < -100 -> "super-refractive" g < -79 -> "enhanced" true -> "normal" end "dN/dh = #{g} (#{label})" end defp sky_note(nil), do: "—" defp 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 end