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.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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue