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:
parent
18d16b0ad6
commit
fbec454917
1 changed files with 119 additions and 15 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue