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.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
<div class="grid grid-cols-2 md:grid-cols-5 gap-3 text-sm">
<div>
<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>
<span class="opacity-60">Bearing</span>
@ -731,7 +800,7 @@ defmodule MicrowavepropWeb.PathLive do
<% end %>
<div>
<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>
@ -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"""
<div class="bg-base-200 rounded-box p-4 mb-6">
@ -970,13 +1042,45 @@ defmodule MicrowavepropWeb.PathLive do
</span>
<% end %>
<span class="text-xs opacity-60 whitespace-nowrap">
{length(@hrrr_points)} / 3 path points
{@total_points} path samples
</span>
<%= if @any_ducting do %>
<span class="badge badge-warning badge-sm whitespace-nowrap">Ducting detected</span>
<%= if @duct_count > 0 do %>
<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 %>
</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">
<table class="table table-xs table-zebra">
<thead>