feat(path): 9-point HRRR sampling + nearest sounding + km/mi display

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 <code>
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.
This commit is contained in:
Graham McIntire 2026-04-18 10:50:24 -05:00
parent 18d16b0ad6
commit fbec454917
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59

View file

@ -11,9 +11,11 @@ defmodule MicrowavepropWeb.PathLive do
alias Microwaveprop.Propagation.SporadicE alias Microwaveprop.Propagation.SporadicE
alias Microwaveprop.Radio.CallsignClient alias Microwaveprop.Radio.CallsignClient
alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo
alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Terrain.TerrainAnalysis alias Microwaveprop.Terrain.TerrainAnalysis
alias Microwaveprop.Weather alias Microwaveprop.Weather
alias Microwaveprop.Weather.Station
@band_options BandConfig.band_options() @band_options BandConfig.band_options()
@ -246,22 +248,25 @@ defmodule MicrowavepropWeb.PathLive do
nil nil
end 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() now = DateTime.utc_now()
midlat = (src.lat + dst.lat) / 2 midlat = (src.lat + dst.lat) / 2
midlon = (src.lon + dst.lon) / 2 midlon = (src.lon + dst.lon) / 2
hrrr_points = hrrr_points =
[ src
{"Source", src.lat, src.lon}, |> path_sample_points(dst, 9)
{"Midpoint", midlat, midlon},
{"Destination", dst.lat, dst.lon}
]
|> Enum.map(&label_hrrr_point(&1, now)) |> Enum.map(&label_hrrr_point(&1, now))
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
hrrr_profiles = Enum.map(hrrr_points, & &1.profile) 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 # Build conditions and score
{conditions, scoring} = build_scoring(hrrr_profiles, src, now, band_config) {conditions, scoring} = build_scoring(hrrr_profiles, src, now, band_config)
@ -294,7 +299,8 @@ defmodule MicrowavepropWeb.PathLive do
forecast: forecast, forecast: forecast,
hrrr_count: length(hrrr_profiles), hrrr_count: length(hrrr_profiles),
hrrr_points: hrrr_points, hrrr_points: hrrr_points,
ionosphere: ionosphere ionosphere: ionosphere,
sounding: sounding
}} }}
end end
end end
@ -379,6 +385,53 @@ defmodule MicrowavepropWeb.PathLive do
end end
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([], _src, _now, _band_config), do: {nil, nil}
defp build_scoring(profiles, src, now, band_config) do 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) when is_float(n), do: :erlang.float_to_binary(n, decimals: 1)
defp format_number(n), do: to_string(n) 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(%DateTime{} = dt), do: Calendar.strftime(dt, "%H:%M")
defp format_utc_hour(_), do: "?" defp format_utc_hour(_), do: "?"
@ -702,7 +771,7 @@ defmodule MicrowavepropWeb.PathLive do
<div class="grid grid-cols-2 md:grid-cols-5 gap-3 text-sm"> <div class="grid grid-cols-2 md:grid-cols-5 gap-3 text-sm">
<div> <div>
<span class="opacity-60">Distance</span> <span class="opacity-60">Distance</span>
<div class="font-mono text-lg">{format_number(@result.dist_km)} km</div> <div class="font-mono text-lg">{format_km_mi(@result.dist_km)}</div>
</div> </div>
<div> <div>
<span class="opacity-60">Bearing</span> <span class="opacity-60">Bearing</span>
@ -731,7 +800,7 @@ defmodule MicrowavepropWeb.PathLive do
<% end %> <% end %>
<div> <div>
<span class="opacity-60">HRRR Data</span> <span class="opacity-60">HRRR Data</span>
<div class="font-mono">{@result.hrrr_count} / 3 points</div> <div class="font-mono">{@result.hrrr_count} / 9 points</div>
</div> </div>
</div> </div>
</div> </div>
@ -914,7 +983,7 @@ defmodule MicrowavepropWeb.PathLive do
<%!-- Atmospheric Profile (HRRR) --%> <%!-- Atmospheric Profile (HRRR) --%>
<%= if @result.hrrr_points != [] do %> <%= if @result.hrrr_points != [] do %>
<.atmospheric_profile hrrr_points={@result.hrrr_points} /> <.atmospheric_profile hrrr_points={@result.hrrr_points} sounding={@result.sounding} />
<% end %> <% end %>
<%!-- Ionosphere (GIRO sporadic-E readout) --%> <%!-- Ionosphere (GIRO sporadic-E readout) --%>
@ -942,19 +1011,22 @@ defmodule MicrowavepropWeb.PathLive do
end end
attr :hrrr_points, :list, required: true attr :hrrr_points, :list, required: true
attr :sounding, :any, default: nil
defp atmospheric_profile(assigns) do defp atmospheric_profile(assigns) do
mid = Enum.find(assigns.hrrr_points, &(&1.label == "Midpoint")) || hd(assigns.hrrr_points) mid = Enum.find(assigns.hrrr_points, &(&1.label == "Midpoint")) || hd(assigns.hrrr_points)
valid_time = mid.profile.valid_time valid_time = mid.profile.valid_time
run_time = Map.get(mid.profile, :run_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 =
assigns assigns
|> assign(:mid, mid) |> assign(:mid, mid)
|> assign(:valid_time, valid_time) |> assign(:valid_time, valid_time)
|> assign(:run_time, run_time) |> assign(:run_time, run_time)
|> assign(:any_ducting, any_ducting?) |> assign(:duct_count, duct_count)
|> assign(:total_points, total_points)
~H""" ~H"""
<div class="bg-base-200 rounded-box p-4 mb-6"> <div class="bg-base-200 rounded-box p-4 mb-6">
@ -970,13 +1042,45 @@ defmodule MicrowavepropWeb.PathLive do
</span> </span>
<% end %> <% end %>
<span class="text-xs opacity-60 whitespace-nowrap"> <span class="text-xs opacity-60 whitespace-nowrap">
{length(@hrrr_points)} / 3 path points {@total_points} path samples
</span> </span>
<%= if @any_ducting do %> <%= if @duct_count > 0 do %>
<span class="badge badge-warning badge-sm whitespace-nowrap">Ducting detected</span> <span class="badge badge-warning badge-sm whitespace-nowrap">
Duct at {@duct_count} / {@total_points} HRRR points
</span>
<% end %>
<%= if @sounding && @sounding.ducting_detected do %>
<span class="badge badge-warning badge-sm whitespace-nowrap">
RAOB {@sounding.station_code} confirms duct
</span>
<% end %> <% end %>
</div> </div>
<%= if @sounding do %>
<div class="text-xs opacity-70 mb-3">
Nearest sounding: <span class="font-mono">{@sounding.station_code}</span>
<%= if @sounding.station_name do %>
({@sounding.station_name})
<% end %>
<%= if @sounding.distance_km do %>
· <span class="font-mono">{format_km_mi(@sounding.distance_km)}</span> from midpoint
<% end %>
· obs
<span class="font-mono">
{Calendar.strftime(@sounding.observed_at, "%Y-%m-%d %H:%M UTC")}
</span>
<%= if @sounding.min_refractivity_gradient do %>
· dN/dh
<span class="font-mono">
{format_number(@sounding.min_refractivity_gradient)} N/km
</span>
<% end %>
<%= if @sounding.precipitable_water_mm do %>
· PWAT <span class="font-mono">{format_number(@sounding.precipitable_water_mm)} mm</span>
<% end %>
</div>
<% end %>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table table-xs table-zebra"> <table class="table table-xs table-zebra">
<thead> <thead>