defmodule MicrowavepropWeb.ContactLive.Show do @moduledoc false use MicrowavepropWeb, :live_view alias Microwaveprop.Radio alias Microwaveprop.Terrain alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.Terrain.TerrainAnalysis alias Microwaveprop.Weather alias Microwaveprop.Weather.HrrrClient alias Microwaveprop.Workers.HrrrFetchWorker @earth_radius_m 6_371_000.0 @impl true def mount(%{"id" => id}, _session, socket) do contact = Radio.get_contact!(id) weather = load_weather(contact) solar = load_solar(contact) hrrr = Weather.hrrr_for_contact(contact) {hrrr, hrrr_status} = maybe_enqueue_hrrr(hrrr, contact) terrain = Terrain.get_terrain_profile(contact.id) elevation_profile = compute_elevation_profile(contact, hrrr) {:ok, assign(socket, page_title: "#{contact.station1} / #{contact.station2}", contact: contact, surface_observations: weather.surface_observations, soundings: weather.soundings, solar: solar, hrrr: hrrr, hrrr_status: hrrr_status, terrain: terrain, elevation_profile: elevation_profile, 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", expanded_soundings: MapSet.new() )} end @impl true def handle_event("sort", %{"field" => field, "table" => "obs"}, socket) do {sort_by, sort_order} = toggle_sort(socket.assigns.obs_sort_by, socket.assigns.obs_sort_order, field) sorted = sort_observations(socket.assigns.surface_observations, sort_by, sort_order) {:noreply, assign(socket, surface_observations: sorted, obs_sort_by: sort_by, obs_sort_order: sort_order )} end def handle_event("toggle_hrrr_profile", _params, socket) do {:noreply, assign(socket, hrrr_profile_expanded: !socket.assigns.hrrr_profile_expanded)} end def handle_event("toggle_terrain", _params, socket) do {:noreply, assign(socket, terrain_expanded: !socket.assigns.terrain_expanded)} end def handle_event("toggle_flag", _params, socket) do contact = Radio.toggle_flagged_invalid!(socket.assigns.contact) {:noreply, assign(socket, contact: contact)} end def handle_event("toggle_profile", %{"id" => id}, socket) do expanded = if MapSet.member?(socket.assigns.expanded_soundings, id) do MapSet.delete(socket.assigns.expanded_soundings, id) else MapSet.put(socket.assigns.expanded_soundings, id) end {:noreply, assign(socket, expanded_soundings: expanded)} end def handle_event("sort", %{"field" => field, "table" => "soundings"}, socket) do {sort_by, sort_order} = toggle_sort(socket.assigns.sounding_sort_by, socket.assigns.sounding_sort_order, field) sorted = sort_soundings(socket.assigns.soundings, sort_by, sort_order) {:noreply, assign(socket, soundings: sorted, sounding_sort_by: sort_by, sounding_sort_order: sort_order )} end defp toggle_sort(current_field, current_order, new_field) do if current_field == new_field && current_order == "asc", do: {new_field, "desc"}, else: {new_field, "asc"} end defp sort_observations(observations, sort_by, sort_order) do sorter = obs_sort_key(sort_by) sorted = Enum.sort_by(observations, sorter) if sort_order == "desc", do: Enum.reverse(sorted), else: sorted end defp obs_sort_key("station_name"), do: fn obs -> obs.station.name || obs.station.station_code end defp obs_sort_key("observed_at"), do: & &1.observed_at defp obs_sort_key("temp_f"), do: &(&1.temp_f || 0) defp obs_sort_key("dewpoint_f"), do: &(&1.dewpoint_f || 0) defp obs_sort_key("relative_humidity"), do: &(&1.relative_humidity || 0) defp obs_sort_key("sea_level_pressure_mb"), do: &(&1.sea_level_pressure_mb || 0) defp obs_sort_key(_), do: fn obs -> obs.station.name || obs.station.station_code end defp sort_soundings(soundings, sort_by, sort_order) do sorter = sounding_sort_key(sort_by) sorted = Enum.sort_by(soundings, sorter) if sort_order == "desc", do: Enum.reverse(sorted), else: sorted end defp sounding_sort_key("station_name"), do: fn s -> s.station.name || s.station.station_code end defp sounding_sort_key("observed_at"), do: & &1.observed_at defp sounding_sort_key("surface_temp_c"), do: &(&1.surface_temp_c || 0) defp sounding_sort_key("surface_refractivity"), do: &(&1.surface_refractivity || 0) defp sounding_sort_key("k_index"), do: &(&1.k_index || 0) defp sounding_sort_key("lifted_index"), do: &(&1.lifted_index || 0) defp sounding_sort_key(_), do: fn s -> s.station.name || s.station.station_code end defp load_weather(contact) do lat = contact.pos1 && contact.pos1["lat"] lon = contact.pos1 && (contact.pos1["lon"] || contact.pos1["lng"]) if lat && lon do Weather.weather_for_contact(%{lat: lat, lon: lon, timestamp: contact.qso_timestamp}, radius_km: 300 ) else %{surface_observations: [], soundings: []} end end defp maybe_enqueue_hrrr(hrrr, _contact) when not is_nil(hrrr), do: {hrrr, :loaded} 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 && hrrr_likely_available?(contact.qso_timestamp) do valid_time = HrrrClient.nearest_hrrr_hour(contact.qso_timestamp) {rlat, rlon} = Weather.round_to_hrrr_grid(lat, lon) Oban.insert( HrrrFetchWorker.new(%{ "lat" => rlat, "lon" => rlon, "valid_time" => DateTime.to_iso8601(valid_time) }) ) {nil, :queued} else {nil, :unavailable} end end # HRRR data is only available from ~2016 onward and kept for ~2 days on NOAA S3. # Historical re-analysis archives go back further but the worker only fetches from the live feed. # For contacts older than 48 hours, HRRR GRIB2 files are no longer on S3. defp hrrr_likely_available?(timestamp) do hours_ago = DateTime.diff(DateTime.utc_now(), timestamp, :hour) hours_ago < 48 end defp load_solar(contact) do contact.qso_timestamp |> DateTime.to_date() |> Weather.get_solar_index() 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> <.link navigate={~p"/contacts"} class="btn btn-sm btn-ghost"> <.icon name="hero-arrow-left" class="w-4 h-4" /> Back to Contacts <%= if @contact.pos1 && @contact.pos2 do %>

Locations approximate, in the center of the grid squares

<% end %> <%= if @elevation_profile do %>
{format_band_ghz(@contact.band)} · {@contact.mode} · {format_dist(@elevation_profile.dist_km)}
{@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 %>

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 %>

No terrain profile available.

<% end %>

Soundings

<%= if @soundings == [] do %>

No soundings found nearby.

<% 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 %>

No solar data available for this date.

<% 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 @hrrr_status do %> <% :queued -> %> HRRR fetch queued — reload in a few minutes. <% :unavailable -> %> HRRR data unavailable for this contact time. <% _ -> %> No HRRR profile available. <% end %>

<% end %>

Surface Observations

<%= if @surface_observations == [] do %>

No surface observations found nearby.

<% else %> <.table id="surface-obs" rows={@surface_observations} row_id={fn obs -> "obs-#{obs.id}" end} sort_by={@obs_sort_by} sort_order={@obs_sort_order} sort_target="obs" > <:col :let={obs} label="Station" sort_field="station_name"> {obs.station.name || obs.station.station_code} <:col :let={obs} label="Time" sort_field="observed_at"> {Calendar.strftime(obs.observed_at, "%H:%M")} <:col :let={obs} label="Temp (F)" sort_field="temp_f">{obs.temp_f || "—"} <:col :let={obs} label="Dewpoint (F)" sort_field="dewpoint_f"> {obs.dewpoint_f || "—"} <:col :let={obs} label="RH%" sort_field="relative_humidity"> {format_number(obs.relative_humidity)} <:col :let={obs} label="Wind">{format_wind(obs)} <:col :let={obs} label="Pressure (mb)" sort_field="sea_level_pressure_mb"> {obs.sea_level_pressure_mb || "—"} <:col :let={obs} label="Sky">{obs.sky_condition || "—"} <% 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_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) 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} <- ElevationClient.fetch_elevation_profile(lat1, lon1, lat2, lon2, 256) 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 = extract_ducts(hrrr, tx_elev) %{ points: Enum.map(analysis.points, fn p -> %{elev: p.elev, beam: p.beam, r1: p.r1, dist_km: p.dist_km} end), freq_mhz: freq_ghz * 1000, dist_km: dist_km, fwd_az: fwd_az, rev_az: rev_az, fwd_el: fwd_el, rev_el: rev_el, verdict: analysis.verdict, first_obstruction_km: first_obs, ducts: ducts } else _ -> nil end end defp extract_ducts(nil, _surface_elev), do: [] defp extract_ducts(hrrr, surface_elev) do case hrrr.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"] } end) _ -> [] end 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 end