From fbec454917b91488fad16d4ce75e73b5cba822a6 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 18 Apr 2026 10:50:24 -0500 Subject: [PATCH] feat(path): 9-point HRRR sampling + nearest sounding + km/mi display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path calculator now samples nine HRRR grid cells evenly along the source→destination line (was three: Source/Midpoint/Destination) so mid-path ducts that endpoint-only queries missed show up in the duct count. The UI reports "Duct at N / 9 HRRR points" instead of a binary flag, and the table lists every sample with its percent-of-path label. Independent RAOB signal: uses the new `Weather.nearest_sounding_to/3` to find the closest sounding within 300 km / ±3 h of the midpoint. If the sounding's `ducting_detected` is true we surface a "RAOB confirms duct" badge — HRRR under-reads thin surface ducts that soundings resolve, so the two signals together are the right cross-check for live duct prediction. Distance formatting: all km values on the path results (top Distance summary, sounding "km from midpoint") now render as `123 km (76 mi)`; under 10 km we keep one decimal so near-LOS paths don't round to zero. --- lib/microwaveprop_web/live/path_live.ex | 134 +++++++++++++++++++++--- 1 file changed, 119 insertions(+), 15 deletions(-) diff --git a/lib/microwaveprop_web/live/path_live.ex b/lib/microwaveprop_web/live/path_live.ex index fe9afddb..f4dad3b7 100644 --- a/lib/microwaveprop_web/live/path_live.ex +++ b/lib/microwaveprop_web/live/path_live.ex @@ -11,9 +11,11 @@ defmodule MicrowavepropWeb.PathLive do alias Microwaveprop.Propagation.SporadicE alias Microwaveprop.Radio.CallsignClient alias Microwaveprop.Radio.Maidenhead + alias Microwaveprop.Repo alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.Terrain.TerrainAnalysis alias Microwaveprop.Weather + alias Microwaveprop.Weather.Station @band_options BandConfig.band_options() @@ -246,22 +248,25 @@ defmodule MicrowavepropWeb.PathLive do nil end - # HRRR profiles along path + # HRRR profiles along path — 9 evenly-spaced samples so mid-path + # ducts that endpoint-only queries miss show up in the duct count. now = DateTime.utc_now() midlat = (src.lat + dst.lat) / 2 midlon = (src.lon + dst.lon) / 2 hrrr_points = - [ - {"Source", src.lat, src.lon}, - {"Midpoint", midlat, midlon}, - {"Destination", dst.lat, dst.lon} - ] + src + |> path_sample_points(dst, 9) |> Enum.map(&label_hrrr_point(&1, now)) |> Enum.reject(&is_nil/1) hrrr_profiles = Enum.map(hrrr_points, & &1.profile) + # Independent duct signal: the nearest RAOB within 300 km / ±3 h of + # the midpoint. HRRR pressure levels systematically under-read thin + # surface ducts that sounding data resolves. + sounding = build_sounding_readout(midlat, midlon, now) + # Build conditions and score {conditions, scoring} = build_scoring(hrrr_profiles, src, now, band_config) @@ -294,7 +299,8 @@ defmodule MicrowavepropWeb.PathLive do forecast: forecast, hrrr_count: length(hrrr_profiles), hrrr_points: hrrr_points, - ionosphere: ionosphere + ionosphere: ionosphere, + sounding: sounding }} end end @@ -379,6 +385,53 @@ defmodule MicrowavepropWeb.PathLive do end end + # Linear interpolation along the great-circle-approximate path. Good + # enough at the path-calculator's typical range (<2000 km); for longer + # paths the path deviates from a rhumb line but we're picking + # HRRR grid cells, not doing precise bearing. + defp path_sample_points(src, dst, count) when count >= 2 do + Enum.map(1..count, fn i -> + t = (i - 1) / (count - 1) + lat = src.lat + (dst.lat - src.lat) * t + lon = src.lon + (dst.lon - src.lon) * t + + label = + cond do + i == 1 -> "Source" + i == count -> "Destination" + i * 2 == count + 1 -> "Midpoint" + true -> "#{round(t * 100)}%" + end + + {label, lat, lon} + end) + end + + defp build_sounding_readout(midlat, midlon, now) do + case Weather.nearest_sounding_to(midlat, midlon, now) do + {:ok, sounding} -> + station = if Ecto.assoc_loaded?(sounding.station), do: sounding.station + station = station || Repo.get(Station, sounding.station_id) + + distance_km = + if station, do: haversine_km(midlat, midlon, station.lat, station.lon) + + %{ + station_code: station && station.station_code, + station_name: station && station.name, + observed_at: sounding.observed_at, + ducting_detected: sounding.ducting_detected, + min_refractivity_gradient: sounding.min_refractivity_gradient, + boundary_layer_depth_m: sounding.boundary_layer_depth_m, + precipitable_water_mm: sounding.precipitable_water_mm, + distance_km: distance_km + } + + {:error, :not_found} -> + nil + end + end + defp build_scoring([], _src, _now, _band_config), do: {nil, nil} defp build_scoring(profiles, src, now, band_config) do @@ -523,6 +576,22 @@ defmodule MicrowavepropWeb.PathLive 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) + # Displays as `123 km (76 mi)`. Distances under 10 km keep one decimal + # so near-LOS paths don't round to zero; everything larger rounds to + # the nearest integer for easier reading. + defp format_km_mi(nil), do: "—" + + defp format_km_mi(km) when is_number(km) do + mi = km / 1.609344 + + {km_str, mi_str} = + if km < 10.0, do: {format_1d(km), format_1d(mi)}, else: {to_string(round(km)), to_string(round(mi))} + + "#{km_str} km (#{mi_str} mi)" + end + + defp format_1d(n), do: :erlang.float_to_binary(n * 1.0, decimals: 1) + defp format_utc_hour(%DateTime{} = dt), do: Calendar.strftime(dt, "%H:%M") defp format_utc_hour(_), do: "?" @@ -702,7 +771,7 @@ defmodule MicrowavepropWeb.PathLive do
Distance -
{format_number(@result.dist_km)} km
+
{format_km_mi(@result.dist_km)}
Bearing @@ -731,7 +800,7 @@ defmodule MicrowavepropWeb.PathLive do <% end %>
HRRR Data -
{@result.hrrr_count} / 3 points
+
{@result.hrrr_count} / 9 points
@@ -914,7 +983,7 @@ defmodule MicrowavepropWeb.PathLive do <%!-- Atmospheric Profile (HRRR) --%> <%= if @result.hrrr_points != [] do %> - <.atmospheric_profile hrrr_points={@result.hrrr_points} /> + <.atmospheric_profile hrrr_points={@result.hrrr_points} sounding={@result.sounding} /> <% end %> <%!-- Ionosphere (GIRO sporadic-E readout) --%> @@ -942,19 +1011,22 @@ defmodule MicrowavepropWeb.PathLive do end attr :hrrr_points, :list, required: true + attr :sounding, :any, default: nil defp atmospheric_profile(assigns) do mid = Enum.find(assigns.hrrr_points, &(&1.label == "Midpoint")) || hd(assigns.hrrr_points) valid_time = mid.profile.valid_time run_time = Map.get(mid.profile, :run_time) - any_ducting? = Enum.any?(assigns.hrrr_points, & &1.profile.ducting_detected) + duct_count = Enum.count(assigns.hrrr_points, & &1.profile.ducting_detected) + total_points = length(assigns.hrrr_points) assigns = assigns |> assign(:mid, mid) |> assign(:valid_time, valid_time) |> assign(:run_time, run_time) - |> assign(:any_ducting, any_ducting?) + |> assign(:duct_count, duct_count) + |> assign(:total_points, total_points) ~H"""
@@ -970,13 +1042,45 @@ defmodule MicrowavepropWeb.PathLive do <% end %> - {length(@hrrr_points)} / 3 path points + {@total_points} path samples - <%= if @any_ducting do %> - Ducting detected + <%= if @duct_count > 0 do %> + + Duct at {@duct_count} / {@total_points} HRRR points + + <% end %> + <%= if @sounding && @sounding.ducting_detected do %> + + RAOB {@sounding.station_code} confirms duct + <% end %>
+ <%= if @sounding do %> +
+ Nearest sounding: {@sounding.station_code} + <%= if @sounding.station_name do %> + ({@sounding.station_name}) + <% end %> + <%= if @sounding.distance_km do %> + · {format_km_mi(@sounding.distance_km)} from midpoint + <% end %> + · obs + + {Calendar.strftime(@sounding.observed_at, "%Y-%m-%d %H:%M UTC")} + + <%= if @sounding.min_refractivity_gradient do %> + · dN/dh + + {format_number(@sounding.min_refractivity_gradient)} N/km + + <% end %> + <%= if @sounding.precipitable_water_mm do %> + · PWAT {format_number(@sounding.precipitable_water_mm)} mm + <% end %> +
+ <% end %> +